librus-python-api 1.8.1__tar.gz → 1.8.3__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.1 → librus_python_api-1.8.3}/API.md +3 -1
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/CHANGELOG.md +41 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/PKG-INFO +7 -4
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/README.md +6 -3
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/pyproject.toml +2 -2
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/__init__.py +3 -2
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/attachments.py +5 -4
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/attendance.py +13 -1
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/config.py +24 -4
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/exceptions.py +3 -1
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/gateway_grades.py +48 -14
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/grade_records.py +65 -1
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/inspect_page.py +5 -2
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/markup.py +17 -8
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/service.py +44 -24
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/transport.py +16 -10
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/uv.lock +212 -212
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/.gitignore +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/LICENSE +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/SECURITY.md +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/_notification_bootstrap.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/_notification_codec.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/_storage.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/_windows_filesystem.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/announcements.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/attachment_routes.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/attendance_frequency.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/budget.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/checkpoint.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/class_free_days.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/completed_lessons.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/detail_fields.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/diagnostics.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/files.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/formative_grades.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/grade_parsers.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/homework_range.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/lifecycle.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/message_content.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/messages.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/models.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/modern_body.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/modern_mailbox.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/modern_messages.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/notification_models.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/notification_persistence.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/notification_workflow.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/notifications.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/parsers.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/parsing.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/persistence.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/py.typed +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/recipients.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/scheduler.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/school_reads.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/school_year_archive.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/sending.py +0 -0
- {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/timetable.py +0 -0
|
@@ -470,7 +470,9 @@ The initial handoff already includes that GET; subsequent sends cost one GET plu
|
|
|
470
470
|
one POST. Expiry before dispatch raises `SessionExpiredError` and leaves
|
|
471
471
|
NOT_DISPATCHED. An exact launch-to-native-login redirect is expiry, not denial.
|
|
472
472
|
Messages-origin failures clear only modern binding/cookies/cache; valid legacy
|
|
473
|
-
sessions stay usable. A
|
|
473
|
+
sessions stay usable. A cancelled mailbox read, or one stopped by a reached limit,
|
|
474
|
+
keeps them, so the next call needs no new launch; a limit that emptied the cookie
|
|
475
|
+
jar does not, and neither does a transport timeout. A later explicit modern call can rebind, but no modern
|
|
474
476
|
read/send is automatically retried. A race after preflight can still yield UNKNOWN.
|
|
475
477
|
|
|
476
478
|
- `modern_identity(*, budget=None, max_age_seconds=0)` returns `ModernIdentity`:
|
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.8.3 (2026-11-06) - Reported grade, attendance and announcement layouts
|
|
4
|
+
|
|
5
|
+
Thank you, @wiktorw and @Witoso, for the reports and the careful synthetic
|
|
6
|
+
reproductions. Signatures are unchanged.
|
|
7
|
+
|
|
8
|
+
- Announcements: "Aktualnie brak ogłoszeń" is an empty page; it used to fail
|
|
9
|
+
as `ParseError` and stop notification polls (#120, reported by @Witoso).
|
|
10
|
+
- Grades: a behaviour-row entry with a grade symbol such as `wz`, linking to
|
|
11
|
+
a behaviour detail, is a `NumericGrade`. Its tooltip's unlabelled first
|
|
12
|
+
line, such as `wzorowe`, is the `category`, as a point entry's first line
|
|
13
|
+
names its category; the day is `Data wystawienia` and `Dodał` stays in
|
|
14
|
+
`metadata`. The whole grades read used to fail (#114, reported by @wiktorw).
|
|
15
|
+
- Grades: a new-schema descriptive table whose semester cells already hold
|
|
16
|
+
plain text, such as `5` or `Brak ocen`, is read as undated semester
|
|
17
|
+
summaries and makes no gateway request. The all view used to fail with
|
|
18
|
+
`UnsupportedCapabilityError` (#115, reported by @wiktorw). Blank cells,
|
|
19
|
+
cells with attributes or markup, mixed layouts and filled tables with
|
|
20
|
+
gateway detail rows still fail.
|
|
21
|
+
- Grades: the new-schema table's header row is no longer read as a subject
|
|
22
|
+
named "Przedmiot" with "Okres 1" and "Okres 2" summaries.
|
|
23
|
+
- Attendance: an absence excused through the e-Usprawiedliwienia module
|
|
24
|
+
carries a fixed notice line in its tooltip, which failed every attendance
|
|
25
|
+
read with `ParseError` (observed live on 2026-11-06 on two logins). The
|
|
26
|
+
notice is now the metadata pair `("Usprawiedliwienie", "dodane za pomocą
|
|
27
|
+
modułu e-Usprawiedliwienia")`; other unlabelled lines still fail.
|
|
28
|
+
- `inspect_page grades` also runs the new-schema table check that `grades()`
|
|
29
|
+
runs in the all view, so a saved page reproduces such a failure offline.
|
|
30
|
+
|
|
31
|
+
The #114 and #115 layouts come from the reporters' synthetic examples, not an
|
|
32
|
+
observed page: the owner's logins have neither. Live checks show they cause no
|
|
33
|
+
regression, not that the fix matches every school's page.
|
|
34
|
+
|
|
35
|
+
## 1.8.2 (2026-11-01) - Modern session kept across cancels, honest User-Agent
|
|
36
|
+
|
|
37
|
+
- A cancelled modern mailbox read or a reached limit no longer drops the modern
|
|
38
|
+
session, so the next call needs no new launch. Other failures still drop it,
|
|
39
|
+
an unrecognized body and a cookie limit, which empties the jar, included.
|
|
40
|
+
- The `User-Agent` carries the package version; it said 0.4 since that release.
|
|
41
|
+
- `config.ATTENDANCE_RESULT_CACHE_SIZE` is renamed `ACCOUNT_RESULT_CACHE_SIZE`:
|
|
42
|
+
it caps every cached read of an account.
|
|
43
|
+
|
|
3
44
|
## 1.8.1 (2026-10-29) - Session recovery, lucky number, consent error and diagnostics
|
|
4
45
|
|
|
5
46
|
Signatures are unchanged; one error kind is added.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: librus-python-api
|
|
3
|
-
Version: 1.8.
|
|
3
|
+
Version: 1.8.3
|
|
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.
|
|
45
|
+
**Status:** `1.8.3` reads behaviour grade symbols, filled descriptive
|
|
46
|
+
grade tables, the "Aktualnie brak ogłoszeń" page and absences excused
|
|
47
|
+
through e-Usprawiedliwienia. `1.8.2` keeps the modern mailbox session across a cancelled read
|
|
48
|
+
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
|
|
46
49
|
with a "Brak dostępu" page, not a login redirect), reads the lucky number
|
|
47
50
|
from the page header and summarizes a page that fails as a whole in
|
|
48
51
|
diagnostics. `1.8.0` reports where a failure
|
|
@@ -63,7 +66,7 @@ See [limitations](#supported-features-and-limitations) below.
|
|
|
63
66
|
Install the exact release from PyPI after its gated publication completes:
|
|
64
67
|
|
|
65
68
|
```sh
|
|
66
|
-
python -m pip install librus-python-api==1.8.
|
|
69
|
+
python -m pip install librus-python-api==1.8.3
|
|
67
70
|
```
|
|
68
71
|
|
|
69
72
|
Pin the exact version qualified by your application. Before publication, use a
|
|
@@ -79,7 +82,7 @@ python -m pip install .
|
|
|
79
82
|
To install a locally built wheel instead:
|
|
80
83
|
|
|
81
84
|
```sh
|
|
82
|
-
python -m pip install ./dist/librus_python_api-1.8.
|
|
85
|
+
python -m pip install ./dist/librus_python_api-1.8.3-py3-none-any.whl
|
|
83
86
|
```
|
|
84
87
|
|
|
85
88
|
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.
|
|
19
|
+
**Status:** `1.8.3` reads behaviour grade symbols, filled descriptive
|
|
20
|
+
grade tables, the "Aktualnie brak ogłoszeń" page and absences excused
|
|
21
|
+
through e-Usprawiedliwienia. `1.8.2` keeps the modern mailbox session across a cancelled read
|
|
22
|
+
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
|
|
20
23
|
with a "Brak dostępu" page, not a login redirect), reads the lucky number
|
|
21
24
|
from the page header and summarizes a page that fails as a whole in
|
|
22
25
|
diagnostics. `1.8.0` reports where a failure
|
|
@@ -37,7 +40,7 @@ See [limitations](#supported-features-and-limitations) below.
|
|
|
37
40
|
Install the exact release from PyPI after its gated publication completes:
|
|
38
41
|
|
|
39
42
|
```sh
|
|
40
|
-
python -m pip install librus-python-api==1.8.
|
|
43
|
+
python -m pip install librus-python-api==1.8.3
|
|
41
44
|
```
|
|
42
45
|
|
|
43
46
|
Pin the exact version qualified by your application. Before publication, use a
|
|
@@ -53,7 +56,7 @@ python -m pip install .
|
|
|
53
56
|
To install a locally built wheel instead:
|
|
54
57
|
|
|
55
58
|
```sh
|
|
56
|
-
python -m pip install ./dist/librus_python_api-1.8.
|
|
59
|
+
python -m pip install ./dist/librus_python_api-1.8.3-py3-none-any.whl
|
|
57
60
|
```
|
|
58
61
|
|
|
59
62
|
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,5 +1,8 @@
|
|
|
1
1
|
"""Independent bounded async client. General live compatibility is unqualified."""
|
|
2
2
|
|
|
3
|
+
# Before the imports: config reads it for the User-Agent.
|
|
4
|
+
__version__ = "1.8.3"
|
|
5
|
+
|
|
3
6
|
from librus_python_api.attachments import AttachmentStream, ModernAttachmentStream
|
|
4
7
|
from librus_python_api.budget import RequestBudget
|
|
5
8
|
from librus_python_api.config import (
|
|
@@ -138,8 +141,6 @@ from librus_python_api.models import (
|
|
|
138
141
|
from librus_python_api.sending import SendAttempt
|
|
139
142
|
from librus_python_api.service import AccountClient, LibrusService
|
|
140
143
|
|
|
141
|
-
__version__ = "1.8.1"
|
|
142
|
-
|
|
143
144
|
__all__ = [
|
|
144
145
|
"ArchiveAbsences",
|
|
145
146
|
"ArchiveAchievement",
|
|
@@ -15,7 +15,10 @@ from librus_python_api.attachment_routes import (
|
|
|
15
15
|
)
|
|
16
16
|
from librus_python_api.budget import RequestBudget
|
|
17
17
|
from librus_python_api.config import ATTACHMENT_MAX_BYTES
|
|
18
|
-
from librus_python_api.exceptions import
|
|
18
|
+
from librus_python_api.exceptions import (
|
|
19
|
+
ErrorKind,
|
|
20
|
+
LibrusError,
|
|
21
|
+
)
|
|
19
22
|
from librus_python_api.lifecycle import join_owned
|
|
20
23
|
from librus_python_api.models import (
|
|
21
24
|
AttachmentHeaders,
|
|
@@ -283,9 +286,7 @@ class ModernAttachmentStream(AttachmentStream):
|
|
|
283
286
|
budget,
|
|
284
287
|
)
|
|
285
288
|
except BaseException as error:
|
|
286
|
-
client.
|
|
287
|
-
if isinstance(error, SessionExpiredError):
|
|
288
|
-
error._messages_origin = True
|
|
289
|
+
client._modern_read_failed(error)
|
|
289
290
|
raise
|
|
290
291
|
await client._transport.stream_download(
|
|
291
292
|
key,
|
|
@@ -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
|
|
|
@@ -31,6 +31,7 @@ from pydantic import (
|
|
|
31
31
|
model_validator,
|
|
32
32
|
)
|
|
33
33
|
|
|
34
|
+
from librus_python_api import __version__
|
|
34
35
|
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
35
36
|
from librus_python_api.models import (
|
|
36
37
|
AttendanceView,
|
|
@@ -113,7 +114,7 @@ UPSTREAM_ORIGINS = MappingProxyType(
|
|
|
113
114
|
OAUTH_QUERY = (("client_id", "46"),)
|
|
114
115
|
SESSION_COOKIE = "oauth_token"
|
|
115
116
|
AUTH_COOKIES = frozenset({SESSION_COOKIE, "DZIENNIKSID", "SDZIENNIKSID"})
|
|
116
|
-
USER_AGENT = "librus-python-api/
|
|
117
|
+
USER_AGENT = f"librus-python-api/{__version__} (independent client)"
|
|
117
118
|
ENDPOINTS: Mapping[str, Endpoint] = MappingProxyType(
|
|
118
119
|
{
|
|
119
120
|
item.operation_id: item
|
|
@@ -435,7 +436,7 @@ ENDPOINTS: Mapping[str, Endpoint] = MappingProxyType(
|
|
|
435
436
|
),
|
|
436
437
|
# Gateway reads behind the grades page's new-schema descriptive
|
|
437
438
|
# table. Routes and field names come from public client code and the
|
|
438
|
-
# official page script
|
|
439
|
+
# official page script; one authorized read parsed four records.
|
|
439
440
|
Endpoint(
|
|
440
441
|
"descriptive_token_info",
|
|
441
442
|
"GET",
|
|
@@ -786,6 +787,7 @@ GRADE_PERIOD_HEADERS = MappingProxyType(
|
|
|
786
787
|
# semester cells and its script loads the grades from the gateway routes above.
|
|
787
788
|
DESCRIPTIVE_TABLE_CLASS = "newSchemaGradesByStudentTable"
|
|
788
789
|
DESCRIPTIVE_ROW_CLASS = "studentRow"
|
|
790
|
+
DESCRIPTIVE_DETAILS_ROW_CLASS = "studentGradesDetailsRow"
|
|
789
791
|
# Gateway account and student identifiers are opaque strings, unlike every
|
|
790
792
|
# other `{id}` route, which takes a numeric ID.
|
|
791
793
|
DESCRIPTIVE_IDENTIFIER = re.compile(r"[A-Za-z0-9_-]{1,128}")
|
|
@@ -837,6 +839,17 @@ ATTENDANCE_SEMESTER_LABELS = MappingProxyType(
|
|
|
837
839
|
}
|
|
838
840
|
)
|
|
839
841
|
ATTENDANCE_EMPTY_MARKERS = frozenset({"", "-", "Brak nieobecności"})
|
|
842
|
+
# Fixed unlabelled tooltip lines Librus appends to an attendance entry, and the
|
|
843
|
+
# metadata pair each becomes. Observed live on 2026-11-06 on two logins: an
|
|
844
|
+
# absence excused through the e-Usprawiedliwienia module.
|
|
845
|
+
ATTENDANCE_TOOLTIP_NOTICES = MappingProxyType(
|
|
846
|
+
{
|
|
847
|
+
"Usprawiedliwienie dodane za pomocą modułu e-Usprawiedliwienia": (
|
|
848
|
+
"Usprawiedliwienie",
|
|
849
|
+
"dodane za pomocą modułu e-Usprawiedliwienia",
|
|
850
|
+
)
|
|
851
|
+
}
|
|
852
|
+
)
|
|
840
853
|
ATTENDANCE_MAX_RECORDS = 2048
|
|
841
854
|
ATTENDANCE_MAX_WINDOW_DAYS = 371
|
|
842
855
|
# Whole-school gateway collections resolve attendance metadata in two requests.
|
|
@@ -848,6 +861,8 @@ ATTENDANCE_DETAIL_PATH_PREFIX = "/przegladaj_nb/szczegoly/"
|
|
|
848
861
|
FORMATIVE_DETAIL_PATH_PREFIX = "/przegladaj_oceny/szczegoly/ksztaltujace/"
|
|
849
862
|
# Recognition only: behaviour-point detail links are classified, never requested.
|
|
850
863
|
BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX = "/przegladaj_oceny/zachowanie_szczegoly/"
|
|
864
|
+
# Tooltip labels of a behaviour grade symbol entry (#114, synthetic example).
|
|
865
|
+
BEHAVIOUR_GRADE_LABELS = frozenset({"Data wystawienia", "Dodał", "Komentarz"})
|
|
851
866
|
# Recognition only: point-grade detail links are classified, never requested.
|
|
852
867
|
POINT_GRADE_DETAIL_PATH_PREFIX = "/przegladaj_oceny_punktowe/szczegoly/"
|
|
853
868
|
FORMATIVE_HEADERS = (
|
|
@@ -862,9 +877,11 @@ FORMATIVE_TEMPLATE_ID = "000000"
|
|
|
862
877
|
FORMATIVE_MAX_TEXT_LENGTH = 65536
|
|
863
878
|
ATTENDANCE_DETAIL_MAX_FIELDS = 32
|
|
864
879
|
ATTENDANCE_METADATA_CACHE_SIZE = 256
|
|
865
|
-
ATTENDANCE_RESULT_CACHE_SIZE = 64
|
|
866
880
|
ATTENDANCE_METADATA_TTL_SECONDS = 3600
|
|
867
881
|
|
|
882
|
+
# Entries of every cached read of one account, oldest first out.
|
|
883
|
+
ACCOUNT_RESULT_CACHE_SIZE = 64
|
|
884
|
+
|
|
868
885
|
ANNOUNCEMENT_MAX_ITEMS = 256
|
|
869
886
|
ANNOUNCEMENT_MAX_FIELD_LENGTH = 1024
|
|
870
887
|
ANNOUNCEMENT_MAX_CONTENT_LENGTH = 65536
|
|
@@ -913,7 +930,10 @@ ANNOUNCEMENT_LABELS = MappingProxyType(
|
|
|
913
930
|
ANNOUNCEMENT_EMPTY_CLASSES = frozenset(
|
|
914
931
|
{"container", "border-red", "resizeable", "center"}
|
|
915
932
|
)
|
|
916
|
-
|
|
933
|
+
# "Aktualnie brak ogłoszeń" reported in #120 on two accounts.
|
|
934
|
+
ANNOUNCEMENT_EMPTY_MARKERS = frozenset(
|
|
935
|
+
{"brak ogłoszeń", "nie ma ogłoszeń", "aktualnie brak ogłoszeń"}
|
|
936
|
+
)
|
|
917
937
|
TIMETABLE_MAX_PERIODS = 32
|
|
918
938
|
TIMETABLE_MAX_LESSONS_PER_SLOT = 16
|
|
919
939
|
TIMETABLE_MAX_CHANGES_PER_SLOT = 16
|
|
@@ -136,7 +136,9 @@ class OperationTimeoutError(LibrusError):
|
|
|
136
136
|
|
|
137
137
|
|
|
138
138
|
class LimitError(LibrusError):
|
|
139
|
-
|
|
139
|
+
# Library-private: the bound was the cookie count and the jar was emptied,
|
|
140
|
+
# so the session behind it is gone.
|
|
141
|
+
_cookies_cleared = False
|
|
140
142
|
|
|
141
143
|
|
|
142
144
|
class ParseError(LibrusError):
|
|
@@ -3,8 +3,14 @@
|
|
|
3
3
|
The grades page of a school on the new descriptive grading schema renders a
|
|
4
4
|
subject table whose semester cells are empty; the page script then loads the
|
|
5
5
|
grades from gateway JSON routes. Routes and field names come from public client
|
|
6
|
-
code and the official page script
|
|
7
|
-
|
|
6
|
+
code and the official page script. One authorized read on 2026-10-28 parsed four
|
|
7
|
+
PartialGrades records on one school; pagination keys are still unknown, so every
|
|
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.
|
|
8
14
|
"""
|
|
9
15
|
|
|
10
16
|
import re
|
|
@@ -19,6 +25,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_valida
|
|
|
19
25
|
|
|
20
26
|
from librus_python_api import markup
|
|
21
27
|
from librus_python_api.config import (
|
|
28
|
+
DESCRIPTIVE_DETAILS_ROW_CLASS,
|
|
22
29
|
DESCRIPTIVE_IDENTIFIER,
|
|
23
30
|
DESCRIPTIVE_MAX_NAME_LENGTH,
|
|
24
31
|
DESCRIPTIVE_MAX_SUBJECTS,
|
|
@@ -330,9 +337,13 @@ def parse_gateway_users(body: bytes) -> Mapping[str, str]:
|
|
|
330
337
|
|
|
331
338
|
@dataclass(frozen=True, slots=True)
|
|
332
339
|
class DescriptiveTable:
|
|
333
|
-
"""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
|
+
"""
|
|
334
344
|
|
|
335
345
|
subjects: Mapping[int, str] = field(repr=False)
|
|
346
|
+
filled: bool = False
|
|
336
347
|
|
|
337
348
|
|
|
338
349
|
def _classes(element: html.HtmlElement) -> set[str]:
|
|
@@ -342,9 +353,11 @@ def _classes(element: html.HtmlElement) -> set[str]:
|
|
|
342
353
|
def read_descriptive_table(document: html.HtmlElement) -> DescriptiveTable | None:
|
|
343
354
|
"""The new-schema subject table, or None when the page has none.
|
|
344
355
|
|
|
345
|
-
Its rows hold an expander, the subject and two
|
|
346
|
-
|
|
347
|
-
|
|
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.
|
|
348
361
|
"""
|
|
349
362
|
tables = [
|
|
350
363
|
table
|
|
@@ -368,20 +381,33 @@ def read_descriptive_table(document: html.HtmlElement) -> DescriptiveTable | Non
|
|
|
368
381
|
tables = populated
|
|
369
382
|
subjects: dict[int, str] = {}
|
|
370
383
|
students: set[str | None] = set()
|
|
384
|
+
layouts: set[bool] = set()
|
|
371
385
|
for row in markup.rows(tables[0]):
|
|
372
386
|
if DESCRIPTIVE_ROW_CLASS not in _classes(row):
|
|
373
387
|
continue
|
|
374
388
|
cells = markup.cells(row)
|
|
375
|
-
if (
|
|
376
|
-
|
|
377
|
-
or not {"micro", "center", "screen-only"} <= _classes(cells[0])
|
|
378
|
-
or [cell.get("data-semester") for cell in cells[2:]] != ["1", "2"]
|
|
379
|
-
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]
|
|
380
391
|
):
|
|
381
392
|
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
382
|
-
|
|
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)
|
|
405
|
+
):
|
|
406
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
407
|
+
layouts.add(filled)
|
|
408
|
+
if not filled and any(
|
|
383
409
|
markup.text(cell) or any(isinstance(node.tag, str) for node in cell)
|
|
384
|
-
for cell in
|
|
410
|
+
for cell in semesters
|
|
385
411
|
):
|
|
386
412
|
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
387
413
|
identifier = row.get("data-subject_id", "")
|
|
@@ -397,7 +423,15 @@ def read_descriptive_table(document: html.HtmlElement) -> DescriptiveTable | Non
|
|
|
397
423
|
if len(students) > 1:
|
|
398
424
|
# One page shows one student; rows of several students are unknown.
|
|
399
425
|
raise LibrusError(ErrorKind.PARSE)
|
|
400
|
-
|
|
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})
|
|
401
435
|
|
|
402
436
|
|
|
403
437
|
def parse_grade_page(body: bytes) -> tuple[GradeRecords, DescriptiveTable | None]:
|
|
@@ -8,6 +8,7 @@ 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,
|
|
@@ -26,6 +27,7 @@ from librus_python_api.config import (
|
|
|
26
27
|
GRADE_PUBLICATION_DATE_LABEL,
|
|
27
28
|
GRADE_PUBLICATION_PERIOD_LABELS,
|
|
28
29
|
GRADE_PUBLICATION_TEACHER_LABEL,
|
|
30
|
+
TOOLTIP_LABELS,
|
|
29
31
|
)
|
|
30
32
|
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
31
33
|
from librus_python_api.formative_grades import is_formative_table, read_formative_table
|
|
@@ -126,6 +128,51 @@ def _numeric(
|
|
|
126
128
|
)
|
|
127
129
|
|
|
128
130
|
|
|
131
|
+
def _behaviour_grade(
|
|
132
|
+
element: html.HtmlElement,
|
|
133
|
+
subject: str,
|
|
134
|
+
semester: Literal[0, 1, 2],
|
|
135
|
+
kind: GradeKind = GradeKind.CURRENT,
|
|
136
|
+
) -> NumericGrade:
|
|
137
|
+
"""A behaviour entry with a grade symbol, such as `wz`, instead of points.
|
|
138
|
+
|
|
139
|
+
Its tooltip has the point entries' layout: an unlabelled first line, then
|
|
140
|
+
`Data wystawienia` and `Dodał`. The first line names the entry, as the
|
|
141
|
+
point category does, so it is the category. `Dodał` stays in metadata. A
|
|
142
|
+
label with digits, a point entry's range labels or any other label fails.
|
|
143
|
+
"""
|
|
144
|
+
heading: list[str] = []
|
|
145
|
+
metadata = markup.tooltip_fields(element, heading)
|
|
146
|
+
raw = markup.text(element)
|
|
147
|
+
if (
|
|
148
|
+
not heading
|
|
149
|
+
# A labelled first line means the heading is missing.
|
|
150
|
+
or heading[0].partition(":")[0].strip() in TOOLTIP_LABELS
|
|
151
|
+
# Symbols carry no digits; points or a range are a point entry.
|
|
152
|
+
or not raw
|
|
153
|
+
or re.search(r"[0-9]", raw)
|
|
154
|
+
or not BEHAVIOUR_GRADE_LABELS >= metadata.keys()
|
|
155
|
+
):
|
|
156
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
157
|
+
href = element.get("href")
|
|
158
|
+
if href is not None and len(href) > GRADE_MAX_METADATA_LENGTH:
|
|
159
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
160
|
+
return NumericGrade(
|
|
161
|
+
subject,
|
|
162
|
+
raw,
|
|
163
|
+
markup.civil_date(metadata.get("Data wystawienia")),
|
|
164
|
+
semester,
|
|
165
|
+
None,
|
|
166
|
+
None,
|
|
167
|
+
heading[0],
|
|
168
|
+
None,
|
|
169
|
+
metadata.get("Komentarz"),
|
|
170
|
+
href,
|
|
171
|
+
tuple(metadata.items()),
|
|
172
|
+
kind,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
129
176
|
def _descriptive(
|
|
130
177
|
box: html.HtmlElement,
|
|
131
178
|
element: html.HtmlElement,
|
|
@@ -284,7 +331,21 @@ def _entries(
|
|
|
284
331
|
if len(anchors) + len(descriptions) != 1:
|
|
285
332
|
raise LibrusError(ErrorKind.PARSE)
|
|
286
333
|
if anchors:
|
|
287
|
-
|
|
334
|
+
behaviour = (
|
|
335
|
+
subject in GRADE_MERGED_SUBJECTS
|
|
336
|
+
and markup.detail_id(
|
|
337
|
+
anchors[0].get("href"), BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX
|
|
338
|
+
)
|
|
339
|
+
is not None
|
|
340
|
+
)
|
|
341
|
+
if behaviour and descriptive:
|
|
342
|
+
# A descriptive record has no category for the first line.
|
|
343
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
344
|
+
record = (
|
|
345
|
+
_behaviour_grade(anchors[0], subject, period, kind)
|
|
346
|
+
if behaviour
|
|
347
|
+
else _numeric(anchors[0], subject, period, kind)
|
|
348
|
+
)
|
|
288
349
|
if descriptive:
|
|
289
350
|
href = record.href
|
|
290
351
|
if href is not None and re.sub(
|
|
@@ -540,6 +601,9 @@ def read_grade_records(document: html.HtmlElement) -> GradeRecords:
|
|
|
540
601
|
_read_numeric_table(table, collection, points=True)
|
|
541
602
|
else:
|
|
542
603
|
for row in markup.rows(table):
|
|
604
|
+
if markup.in_header(row):
|
|
605
|
+
# The new-schema table's column labels, not a subject.
|
|
606
|
+
continue
|
|
543
607
|
cells = markup.cells(row)
|
|
544
608
|
if _descriptive_row(cells):
|
|
545
609
|
_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
|
+
# The all 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
|
|
|
@@ -81,11 +81,14 @@ def colspan(cell: html.HtmlElement) -> int:
|
|
|
81
81
|
return span
|
|
82
82
|
|
|
83
83
|
|
|
84
|
-
def tooltip_fields(
|
|
84
|
+
def tooltip_fields(
|
|
85
|
+
element: html.HtmlElement, heading: list[str] | None = None
|
|
86
|
+
) -> dict[str, str]:
|
|
85
87
|
"""Parse a 'Label: value<br>' title attribute into unique labelled fields.
|
|
86
88
|
|
|
87
89
|
A free-text field may span lines; an unlabelled line after it continues
|
|
88
|
-
its value. Any other unlabelled line is an unknown layout.
|
|
90
|
+
its value. Any other unlabelled line is an unknown layout. With `heading`,
|
|
91
|
+
the first line is taken whole, colon included, and appended to it.
|
|
89
92
|
"""
|
|
90
93
|
title = element.get("title", "")
|
|
91
94
|
if len(title) > GRADE_MAX_METADATA_LENGTH:
|
|
@@ -98,12 +101,10 @@ def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
|
|
|
98
101
|
for chunk in chunks:
|
|
99
102
|
if not chunk.strip():
|
|
100
103
|
continue
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
else " ".join(chunk.split())
|
|
106
|
-
)
|
|
104
|
+
rendered = tooltip_line(chunk)
|
|
105
|
+
if heading is not None and not heading:
|
|
106
|
+
heading.append(rendered)
|
|
107
|
+
continue
|
|
107
108
|
key, separator, value = rendered.partition(":")
|
|
108
109
|
key, value = key.strip(), value.strip()
|
|
109
110
|
if not separator and last in TOOLTIP_MULTILINE_LABELS:
|
|
@@ -116,6 +117,14 @@ def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
|
|
|
116
117
|
return result
|
|
117
118
|
|
|
118
119
|
|
|
120
|
+
def tooltip_line(chunk: str) -> str:
|
|
121
|
+
"""One tooltip line's text, whitespace normalized."""
|
|
122
|
+
# Tooltips are HTML inside an attribute; parse markup only where present.
|
|
123
|
+
if "<" in chunk:
|
|
124
|
+
return text(parse_html_document(chunk.encode()))
|
|
125
|
+
return " ".join(chunk.split())
|
|
126
|
+
|
|
127
|
+
|
|
119
128
|
def civil_date(value: str | None) -> date:
|
|
120
129
|
"""An ISO date, optionally followed by its weekday label in parentheses."""
|
|
121
130
|
match = re.fullmatch(r"([0-9]{4}-[0-9]{2}-[0-9]{2})(?: \(([^()]+)\))?", value or "")
|