librus-python-api 1.3.0__tar.gz → 1.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/API.md +10 -2
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/CHANGELOG.md +19 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/PKG-INFO +8 -8
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/README.md +7 -7
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/__init__.py +3 -1
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/config.py +12 -0
- librus_python_api-1.4.0/src/librus_python_api/formative_grades.py +173 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/grade_records.py +29 -1
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/markup.py +14 -3
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/models.py +44 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/service.py +5 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/.gitignore +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/LICENSE +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/SECURITY.md +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/pyproject.toml +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/_notification_bootstrap.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/_notification_codec.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/_storage.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/_windows_filesystem.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/announcements.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/attachment_routes.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/attachments.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/attendance.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/attendance_frequency.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/budget.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/checkpoint.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/completed_lessons.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/detail_fields.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/diagnostics.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/exceptions.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/files.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/grade_parsers.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/homework_range.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/lifecycle.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/message_content.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/messages.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/modern_body.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/modern_mailbox.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/modern_messages.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/notification_models.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/notification_persistence.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/notification_workflow.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/notifications.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/parsers.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/parsing.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/persistence.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/py.typed +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/recipients.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/scheduler.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/school_reads.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/school_year_archive.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/sending.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/timetable.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/transport.py +0 -0
- {librus_python_api-1.3.0 → librus_python_api-1.4.0}/uv.lock +0 -0
|
@@ -229,8 +229,16 @@ See [archive evidence and limitations](contracts/grades.md#school-year-archive-1
|
|
|
229
229
|
|
|
230
230
|
The observed grade-detail page added no fields beyond the list tooltip, so
|
|
231
231
|
there is no separate per-grade detail request. This finding is limited to the
|
|
232
|
-
observed layout.
|
|
233
|
-
|
|
232
|
+
observed layout.
|
|
233
|
+
|
|
234
|
+
Formative assessments (1.4.0) come from the same page with no extra request.
|
|
235
|
+
`grades().records.formative` and `grades_window(...).formative` hold
|
|
236
|
+
`FormativeGrade(subject, text, category, semester, day, assessment_type,
|
|
237
|
+
detail_id)`; the observation card ("KARTA SPOSTRZEŻEŃ") appears as a subject
|
|
238
|
+
value. The same items stay in `numeric`/`descriptive` unchanged; their
|
|
239
|
+
`formative_id` property (computed, not a field, so stored notification
|
|
240
|
+
identities are unchanged) links them to `detail_id`. A page without the
|
|
241
|
+
section returns `()`. See [formative grades](contracts/grades.md#formative-grades-and-the-observation-card-140).
|
|
234
242
|
|
|
235
243
|
## Attendance
|
|
236
244
|
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.0 (2026-10-08) - Formative assessments and the observation card
|
|
4
|
+
|
|
5
|
+
Compatible minor release. Existing fields, results and stored notification
|
|
6
|
+
identities are unchanged. One new failure mode: a page whose formative table
|
|
7
|
+
is recognised but uses unknown markup (for example an empty-state row, markup
|
|
8
|
+
inside the assessment text or a stray formative link) now fails the grades
|
|
9
|
+
read instead of being ignored, rather than returning partial output.
|
|
10
|
+
|
|
11
|
+
- Add `GradeRecords.formative` and `GradeWindow.formative`: `FormativeGrade`
|
|
12
|
+
records from the grades page's "Oceny kształtujące" table, including the
|
|
13
|
+
"KARTA SPOSTRZEŻEŃ" observation card, read from the page `grades()` already
|
|
14
|
+
fetches. No request is added; formative detail links are never followed.
|
|
15
|
+
- Add the computed `formative_id` property to `NumericGrade` and
|
|
16
|
+
`DescriptiveGrade`, identifying grid records that mirror a formative item.
|
|
17
|
+
It is not a dataclass field, so grade notification identities stay stable.
|
|
18
|
+
- Reject unknown formative markup and stray formative links instead of
|
|
19
|
+
dropping them; a page without the section has no formative items.
|
|
20
|
+
- The weekly live check reports formative counts for the grades step.
|
|
21
|
+
|
|
3
22
|
## 1.3.0 (2026-10-07) - School-year archive read
|
|
4
23
|
|
|
5
24
|
Compatible minor release. Existing calls and results are unchanged.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: librus-python-api
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Independent bounded async Librus Synergia client
|
|
5
5
|
Project-URL: Repository, https://github.com/krzysztofbury/librus-python-api
|
|
6
6
|
Project-URL: Issues, https://github.com/krzysztofbury/librus-python-api/issues
|
|
@@ -42,9 +42,9 @@ 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.
|
|
46
|
-
|
|
47
|
-
archives on two parent logins; see [verification](VERIFICATION.md).
|
|
45
|
+
**Status:** `1.4.0` adds formative assessments, including the observation card,
|
|
46
|
+
to grade results. The 1.3.0 archive read passed installed live checks for
|
|
47
|
+
populated and empty archives on two parent logins; see [verification](VERIFICATION.md).
|
|
48
48
|
The stable 1.x API does not guarantee every school layout. School features depend
|
|
49
49
|
on what each account can access. See the [compatibility policy](API.md#compatibility-policy).
|
|
50
50
|
See [limitations](#supported-features-and-limitations) below.
|
|
@@ -54,7 +54,7 @@ See [limitations](#supported-features-and-limitations) below.
|
|
|
54
54
|
Install the exact release from PyPI after its gated publication completes:
|
|
55
55
|
|
|
56
56
|
```sh
|
|
57
|
-
python -m pip install librus-python-api==1.
|
|
57
|
+
python -m pip install librus-python-api==1.4.0
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
Pin the exact version qualified by your application. Before publication, use a
|
|
@@ -70,7 +70,7 @@ python -m pip install .
|
|
|
70
70
|
To install a locally built wheel instead:
|
|
71
71
|
|
|
72
72
|
```sh
|
|
73
|
-
python -m pip install ./dist/librus_python_api-1.
|
|
73
|
+
python -m pip install ./dist/librus_python_api-1.4.0-py3-none-any.whl
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
No CLI or background process is installed: import the library in your own program.
|
|
@@ -245,8 +245,8 @@ removed in 0.7.
|
|
|
245
245
|
| Communication | Legacy/modern message lists and content, recipient discovery, bounded attachment streams and explicit sending |
|
|
246
246
|
| Application workflows | Shared multi-account limits, caching, notification checkpoints, optional durable send/notification stores |
|
|
247
247
|
|
|
248
|
-
Completed lessons may be disabled by the school. Behaviour notes
|
|
249
|
-
|
|
248
|
+
Completed lessons may be disabled by the school. Behaviour notes are not
|
|
249
|
+
implemented. Some recipient, archive and receipt layouts remain
|
|
250
250
|
unqualified; backend acceptance is not proof of delivery. Tests cover supported
|
|
251
251
|
contracts, not every school or role. Detailed coverage is in the
|
|
252
252
|
[verification log][verification] and [roadmap][roadmap].
|
|
@@ -16,9 +16,9 @@ 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.
|
|
20
|
-
|
|
21
|
-
archives on two parent logins; see [verification](VERIFICATION.md).
|
|
19
|
+
**Status:** `1.4.0` adds formative assessments, including the observation card,
|
|
20
|
+
to grade results. The 1.3.0 archive read passed installed live checks for
|
|
21
|
+
populated and empty archives on two parent logins; see [verification](VERIFICATION.md).
|
|
22
22
|
The stable 1.x API does not guarantee every school layout. School features depend
|
|
23
23
|
on what each account can access. See the [compatibility policy](API.md#compatibility-policy).
|
|
24
24
|
See [limitations](#supported-features-and-limitations) below.
|
|
@@ -28,7 +28,7 @@ See [limitations](#supported-features-and-limitations) below.
|
|
|
28
28
|
Install the exact release from PyPI after its gated publication completes:
|
|
29
29
|
|
|
30
30
|
```sh
|
|
31
|
-
python -m pip install librus-python-api==1.
|
|
31
|
+
python -m pip install librus-python-api==1.4.0
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
Pin the exact version qualified by your application. Before publication, use a
|
|
@@ -44,7 +44,7 @@ python -m pip install .
|
|
|
44
44
|
To install a locally built wheel instead:
|
|
45
45
|
|
|
46
46
|
```sh
|
|
47
|
-
python -m pip install ./dist/librus_python_api-1.
|
|
47
|
+
python -m pip install ./dist/librus_python_api-1.4.0-py3-none-any.whl
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
No CLI or background process is installed: import the library in your own program.
|
|
@@ -219,8 +219,8 @@ removed in 0.7.
|
|
|
219
219
|
| Communication | Legacy/modern message lists and content, recipient discovery, bounded attachment streams and explicit sending |
|
|
220
220
|
| Application workflows | Shared multi-account limits, caching, notification checkpoints, optional durable send/notification stores |
|
|
221
221
|
|
|
222
|
-
Completed lessons may be disabled by the school. Behaviour notes
|
|
223
|
-
|
|
222
|
+
Completed lessons may be disabled by the school. Behaviour notes are not
|
|
223
|
+
implemented. Some recipient, archive and receipt layouts remain
|
|
224
224
|
unqualified; backend acceptance is not proof of delivery. Tests cover supported
|
|
225
225
|
contracts, not every school or role. Detailed coverage is in the
|
|
226
226
|
[verification log][verification] and [roadmap][roadmap].
|
|
@@ -44,6 +44,7 @@ from librus_python_api.models import (
|
|
|
44
44
|
DetailField,
|
|
45
45
|
DetailFieldKey,
|
|
46
46
|
FinalGrades,
|
|
47
|
+
FormativeGrade,
|
|
47
48
|
FrequencyMeasure,
|
|
48
49
|
GatewayAttendance,
|
|
49
50
|
GatewayAttendanceRecord,
|
|
@@ -135,7 +136,7 @@ from librus_python_api.models import (
|
|
|
135
136
|
from librus_python_api.sending import SendAttempt
|
|
136
137
|
from librus_python_api.service import AccountClient, LibrusService
|
|
137
138
|
|
|
138
|
-
__version__ = "1.
|
|
139
|
+
__version__ = "1.4.0"
|
|
139
140
|
|
|
140
141
|
__all__ = [
|
|
141
142
|
"ArchiveAbsences",
|
|
@@ -251,6 +252,7 @@ __all__ = [
|
|
|
251
252
|
"DescriptiveGrade",
|
|
252
253
|
"DescriptiveGradeSummary",
|
|
253
254
|
"FinalGrades",
|
|
255
|
+
"FormativeGrade",
|
|
254
256
|
"GradeKind",
|
|
255
257
|
"GradeRecords",
|
|
256
258
|
"Grades",
|
|
@@ -721,6 +721,18 @@ ATTENDANCE_MAX_LESSONS = 4096
|
|
|
721
721
|
ATTENDANCE_MAX_SUBJECTS = 1024
|
|
722
722
|
# Collection recognition and detail retrieval share this central path family.
|
|
723
723
|
ATTENDANCE_DETAIL_PATH_PREFIX = "/przegladaj_nb/szczegoly/"
|
|
724
|
+
# Recognition only: formative detail links are classified, never requested.
|
|
725
|
+
FORMATIVE_DETAIL_PATH_PREFIX = "/przegladaj_oceny/szczegoly/ksztaltujace/"
|
|
726
|
+
FORMATIVE_HEADERS = (
|
|
727
|
+
"przedmiot",
|
|
728
|
+
"ocena kształtująca",
|
|
729
|
+
"kategoria",
|
|
730
|
+
"okres",
|
|
731
|
+
"data",
|
|
732
|
+
"typ",
|
|
733
|
+
)
|
|
734
|
+
FORMATIVE_TEMPLATE_ID = "000000"
|
|
735
|
+
FORMATIVE_MAX_TEXT_LENGTH = 65536
|
|
724
736
|
ATTENDANCE_DETAIL_MAX_FIELDS = 32
|
|
725
737
|
ATTENDANCE_METADATA_CACHE_SIZE = 256
|
|
726
738
|
ATTENDANCE_RESULT_CACHE_SIZE = 64
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""The grades page's formative-assessment table ("Oceny kształtujące").
|
|
2
|
+
|
|
3
|
+
Authored from an owner-authorized structural observation; fixtures are
|
|
4
|
+
synthetic. The table sits on the page `grades()` already reads, so no request
|
|
5
|
+
is added. Its subject may be the pseudo-subject "KARTA SPOSTRZEŻEŃ". Unknown
|
|
6
|
+
markup is unsupported; contradictions inside the known layout are parse errors.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Literal, NoReturn, cast
|
|
12
|
+
|
|
13
|
+
from lxml import html
|
|
14
|
+
|
|
15
|
+
from librus_python_api import markup
|
|
16
|
+
from librus_python_api.config import (
|
|
17
|
+
FORMATIVE_DETAIL_PATH_PREFIX,
|
|
18
|
+
FORMATIVE_HEADERS,
|
|
19
|
+
FORMATIVE_MAX_TEXT_LENGTH,
|
|
20
|
+
FORMATIVE_TEMPLATE_ID,
|
|
21
|
+
GRADE_MAX_RECORDS,
|
|
22
|
+
)
|
|
23
|
+
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
24
|
+
from librus_python_api.models import FormativeGrade
|
|
25
|
+
|
|
26
|
+
_SPAN = re.compile(r"[1-9][0-9]{0,5}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _fail(kind: ErrorKind) -> NoReturn:
|
|
30
|
+
raise LibrusError(kind)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_formative_table(table: html.HtmlElement) -> bool:
|
|
34
|
+
# Cheap structural gates first: other tables' header text is never read.
|
|
35
|
+
if not {"stretch", "decorated"} <= set(table.get("class", "").split()):
|
|
36
|
+
return False
|
|
37
|
+
heads = [row for row in markup.rows(table) if markup.in_header(row)]
|
|
38
|
+
if len(heads) != 1:
|
|
39
|
+
return False
|
|
40
|
+
cells = markup.cells(heads[0])
|
|
41
|
+
return len(cells) == len(FORMATIVE_HEADERS) and [
|
|
42
|
+
markup.text(cell).casefold() for cell in cells
|
|
43
|
+
] == list(FORMATIVE_HEADERS)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _single(cell: html.HtmlElement) -> None:
|
|
47
|
+
if cell.get("colspan", "1") != "1" or cell.get("rowspan", "1") != "1":
|
|
48
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _plain(cell: html.HtmlElement) -> str:
|
|
52
|
+
_single(cell)
|
|
53
|
+
if any(isinstance(node.tag, str) for node in cell.iterdescendants()):
|
|
54
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
55
|
+
return markup.text(cell)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _link(cell: html.HtmlElement) -> tuple[str, str]:
|
|
59
|
+
"""The detail ID and full text of the assessment cell.
|
|
60
|
+
|
|
61
|
+
Observed: one link whose optional first child is an empty coloured
|
|
62
|
+
`span.grade-box` marker, followed by the text. Anything else is new markup.
|
|
63
|
+
"""
|
|
64
|
+
_single(cell)
|
|
65
|
+
anchors = [node for node in cell if isinstance(node.tag, str)]
|
|
66
|
+
if len(anchors) != 1 or anchors[0].tag != "a":
|
|
67
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
68
|
+
anchor = anchors[0]
|
|
69
|
+
children = [node for node in anchor.iterdescendants() if isinstance(node.tag, str)]
|
|
70
|
+
if children:
|
|
71
|
+
marker = children[0]
|
|
72
|
+
if (
|
|
73
|
+
len(children) != 1
|
|
74
|
+
or marker.getparent() is not anchor
|
|
75
|
+
or anchor.index(marker) != 0
|
|
76
|
+
or marker.tag != "span"
|
|
77
|
+
or "grade-box" not in marker.get("class", "").split()
|
|
78
|
+
or markup.text(marker)
|
|
79
|
+
):
|
|
80
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
81
|
+
identifier = markup.detail_id(anchor.get("href"), FORMATIVE_DETAIL_PATH_PREFIX)
|
|
82
|
+
if identifier is None:
|
|
83
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
84
|
+
return identifier, markup.text(anchor, FORMATIVE_MAX_TEXT_LENGTH)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _template(row: html.HtmlElement) -> bool:
|
|
88
|
+
"""A hidden, empty template entry (ID 000000) rendered for scripts.
|
|
89
|
+
|
|
90
|
+
Not observed inside this table (the page's observed template is an ordinary
|
|
91
|
+
grade link in a separate hidden table); kept narrow on purpose.
|
|
92
|
+
"""
|
|
93
|
+
hidden = "display:none" in row.get("style", "").replace(" ", "").casefold()
|
|
94
|
+
links = [
|
|
95
|
+
markup.detail_id(anchor.get("href"), FORMATIVE_DETAIL_PATH_PREFIX)
|
|
96
|
+
for anchor in row.iter("a")
|
|
97
|
+
]
|
|
98
|
+
if not hidden or links != [FORMATIVE_TEMPLATE_ID]:
|
|
99
|
+
return False
|
|
100
|
+
if any(markup.text(anchor) for anchor in row.iter("a")):
|
|
101
|
+
_fail(ErrorKind.PARSE)
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _item(subject: str, cells: list[html.HtmlElement]) -> FormativeGrade:
|
|
106
|
+
if len(cells) != 5:
|
|
107
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
108
|
+
identifier, text = _link(cells[0])
|
|
109
|
+
category = _plain(cells[1])
|
|
110
|
+
period = _plain(cells[2])
|
|
111
|
+
day = markup.civil_date(_plain(cells[3]))
|
|
112
|
+
kind = _plain(cells[4])
|
|
113
|
+
if not text or period not in ("1", "2") or identifier == FORMATIVE_TEMPLATE_ID:
|
|
114
|
+
_fail(ErrorKind.PARSE)
|
|
115
|
+
return FormativeGrade(
|
|
116
|
+
subject,
|
|
117
|
+
text,
|
|
118
|
+
category,
|
|
119
|
+
cast(Literal[1, 2], int(period)),
|
|
120
|
+
day,
|
|
121
|
+
kind,
|
|
122
|
+
identifier,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def read_formative_table(
|
|
127
|
+
table: html.HtmlElement, check_size: Callable[[int], None]
|
|
128
|
+
) -> tuple[FormativeGrade, ...]:
|
|
129
|
+
"""Rows in page order; a subject TH may span the rows of its group."""
|
|
130
|
+
if any(nested is not table for nested in table.iter("table")):
|
|
131
|
+
_fail(ErrorKind.PARSE)
|
|
132
|
+
items: list[FormativeGrade] = []
|
|
133
|
+
seen: set[str] = set()
|
|
134
|
+
subject, remaining = "", 0
|
|
135
|
+
for row in markup.rows(table):
|
|
136
|
+
if markup.in_header(row):
|
|
137
|
+
continue
|
|
138
|
+
cells = markup.cells(row)
|
|
139
|
+
if next(row.iterancestors("tfoot"), None) is not None:
|
|
140
|
+
if len(cells) != 1 or markup.text(cells[0]):
|
|
141
|
+
_fail(ErrorKind.PARSE)
|
|
142
|
+
continue
|
|
143
|
+
if _template(row):
|
|
144
|
+
continue
|
|
145
|
+
if remaining == 0:
|
|
146
|
+
if not cells or cells[0].tag != "th":
|
|
147
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
148
|
+
head = cells[0]
|
|
149
|
+
span = head.get("rowspan", "1")
|
|
150
|
+
if head.get("colspan", "1") != "1" or not _SPAN.fullmatch(span):
|
|
151
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
152
|
+
remaining = int(span)
|
|
153
|
+
if remaining > GRADE_MAX_RECORDS:
|
|
154
|
+
_fail(ErrorKind.LIMIT)
|
|
155
|
+
if any(isinstance(node.tag, str) for node in head.iterdescendants()):
|
|
156
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
157
|
+
subject = markup.text(head)
|
|
158
|
+
if not subject:
|
|
159
|
+
_fail(ErrorKind.PARSE)
|
|
160
|
+
cells = cells[1:]
|
|
161
|
+
elif cells and cells[0].tag == "th":
|
|
162
|
+
# A new subject before the spanning group ended.
|
|
163
|
+
_fail(ErrorKind.PARSE)
|
|
164
|
+
remaining -= 1
|
|
165
|
+
item = _item(subject, cells)
|
|
166
|
+
if item.detail_id in seen:
|
|
167
|
+
_fail(ErrorKind.PARSE)
|
|
168
|
+
seen.add(item.detail_id)
|
|
169
|
+
items.append(item)
|
|
170
|
+
check_size(len(items))
|
|
171
|
+
if remaining:
|
|
172
|
+
_fail(ErrorKind.PARSE)
|
|
173
|
+
return tuple(items)
|
|
@@ -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
|
+
FORMATIVE_DETAIL_PATH_PREFIX,
|
|
11
12
|
GRADE_AVERAGE_HEADERS,
|
|
12
13
|
GRADE_BODY_PREFIX_COLUMNS,
|
|
13
14
|
GRADE_CURRENT_HEADER,
|
|
@@ -23,11 +24,13 @@ from librus_python_api.config import (
|
|
|
23
24
|
GRADE_PUBLICATION_TEACHER_LABEL,
|
|
24
25
|
)
|
|
25
26
|
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
27
|
+
from librus_python_api.formative_grades import is_formative_table, read_formative_table
|
|
26
28
|
from librus_python_api.grade_parsers import _locate, _subject_rows, _summary
|
|
27
29
|
from librus_python_api.models import (
|
|
28
30
|
Availability,
|
|
29
31
|
DescriptiveGrade,
|
|
30
32
|
DescriptiveGradeSummary,
|
|
33
|
+
FormativeGrade,
|
|
31
34
|
GradeKind,
|
|
32
35
|
GradeRecords,
|
|
33
36
|
GradeSummaryValue,
|
|
@@ -138,14 +141,22 @@ class _Collection:
|
|
|
138
141
|
subjects: set[tuple[bool, str]] = field(default_factory=set, repr=False)
|
|
139
142
|
consumed: set[html.HtmlElement] = field(default_factory=set, repr=False)
|
|
140
143
|
publication_rows: set[html.HtmlElement] = field(default_factory=set, repr=False)
|
|
144
|
+
formative: int = 0
|
|
141
145
|
|
|
142
146
|
def check_size(self) -> None:
|
|
143
147
|
if (
|
|
144
|
-
len(self.numeric)
|
|
148
|
+
len(self.numeric)
|
|
149
|
+
+ len(self.descriptive)
|
|
150
|
+
+ len(self.descriptive_summaries)
|
|
151
|
+
+ self.formative
|
|
145
152
|
> GRADE_MAX_RECORDS
|
|
146
153
|
):
|
|
147
154
|
raise LibrusError(ErrorKind.LIMIT)
|
|
148
155
|
|
|
156
|
+
def count_formative(self, count: int) -> None:
|
|
157
|
+
self.formative = count
|
|
158
|
+
self.check_size()
|
|
159
|
+
|
|
149
160
|
def subject(self, value: str, *, descriptive: bool = False) -> None:
|
|
150
161
|
key = (descriptive, value)
|
|
151
162
|
if not value or key in self.subjects:
|
|
@@ -400,7 +411,14 @@ def parse_grade_records(body: bytes) -> GradeRecords:
|
|
|
400
411
|
numeric_tables.append(table)
|
|
401
412
|
if len(numeric_tables) > 1:
|
|
402
413
|
raise LibrusError(ErrorKind.PARSE)
|
|
414
|
+
formative_tables = [table for table in tables if is_formative_table(table)]
|
|
415
|
+
if len(formative_tables) > 1:
|
|
416
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
417
|
+
formative: tuple[FormativeGrade, ...] = ()
|
|
403
418
|
for table in tables:
|
|
419
|
+
if table in formative_tables:
|
|
420
|
+
formative = read_formative_table(table, collection.count_formative)
|
|
421
|
+
continue
|
|
404
422
|
_read_publications(table, collection)
|
|
405
423
|
if table in numeric_tables:
|
|
406
424
|
_read_numeric_table(table, collection)
|
|
@@ -434,6 +452,15 @@ def parse_grade_records(body: bytes) -> GradeRecords:
|
|
|
434
452
|
):
|
|
435
453
|
continue
|
|
436
454
|
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
455
|
+
for anchor in document.iter("a"):
|
|
456
|
+
# A formative link outside both known tables is a new layout.
|
|
457
|
+
if markup.detail_id(
|
|
458
|
+
anchor.get("href"), FORMATIVE_DETAIL_PATH_PREFIX
|
|
459
|
+
) and not any(
|
|
460
|
+
parent in numeric_tables or parent in formative_tables
|
|
461
|
+
for parent in anchor.iterancestors("table")
|
|
462
|
+
):
|
|
463
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
437
464
|
if not collection.subjects and not collection.descriptive:
|
|
438
465
|
raise LibrusError(ErrorKind.PARSE)
|
|
439
466
|
for descriptive, subject in sorted(collection.subjects):
|
|
@@ -451,4 +478,5 @@ def parse_grade_records(body: bytes) -> GradeRecords:
|
|
|
451
478
|
tuple(collection.descriptive),
|
|
452
479
|
tuple(collection.averages),
|
|
453
480
|
tuple(collection.descriptive_summaries),
|
|
481
|
+
formative,
|
|
454
482
|
)
|
|
@@ -131,8 +131,19 @@ def attendance_detail_id(element: html.HtmlElement) -> str | None:
|
|
|
131
131
|
)
|
|
132
132
|
if match is None:
|
|
133
133
|
return None
|
|
134
|
+
return detail_id(match[2], ATTENDANCE_DETAIL_PATH_PREFIX)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def detail_id(link: str | None, prefix: str) -> str | None:
|
|
138
|
+
"""The numeric ID of a relative or same-origin Synergia detail link.
|
|
139
|
+
|
|
140
|
+
Recognition only: the link is never followed. Queries, fragments, other
|
|
141
|
+
origins, credentials and non-digit IDs are not detail links.
|
|
142
|
+
"""
|
|
143
|
+
if link is None or len(link) > GRADE_MAX_METADATA_LENGTH:
|
|
144
|
+
return None
|
|
134
145
|
try:
|
|
135
|
-
target = urlsplit(
|
|
146
|
+
target = urlsplit(link)
|
|
136
147
|
except ValueError:
|
|
137
148
|
return None
|
|
138
149
|
if target.query or target.fragment or target.username or target.password:
|
|
@@ -141,7 +152,7 @@ def attendance_detail_id(element: html.HtmlElement) -> str | None:
|
|
|
141
152
|
expected = urlsplit(UPSTREAM_ORIGINS["synergia"])
|
|
142
153
|
if (target.scheme, target.netloc) != (expected.scheme, expected.netloc):
|
|
143
154
|
return None
|
|
144
|
-
if not target.path.startswith(
|
|
155
|
+
if not target.path.startswith(prefix):
|
|
145
156
|
return None
|
|
146
|
-
identifier = target.path[len(
|
|
157
|
+
identifier = target.path[len(prefix) :]
|
|
147
158
|
return identifier if re.fullmatch(r"[0-9]{1,64}", identifier) else None
|
|
@@ -1019,6 +1019,19 @@ class NumericGrade:
|
|
|
1019
1019
|
metadata: tuple[tuple[str, str], ...] = field(repr=False)
|
|
1020
1020
|
kind: GradeKind = GradeKind.CURRENT
|
|
1021
1021
|
|
|
1022
|
+
@property
|
|
1023
|
+
def formative_id(self) -> str | None:
|
|
1024
|
+
"""The formative-assessment ID when `href` is a formative detail link.
|
|
1025
|
+
|
|
1026
|
+
Computed, not a field: canonical notification IDs hash the record's
|
|
1027
|
+
fields, so stored identities stay unchanged.
|
|
1028
|
+
"""
|
|
1029
|
+
# Lazy import: config imports this module.
|
|
1030
|
+
from librus_python_api.config import FORMATIVE_DETAIL_PATH_PREFIX
|
|
1031
|
+
from librus_python_api.markup import detail_id
|
|
1032
|
+
|
|
1033
|
+
return detail_id(self.href, FORMATIVE_DETAIL_PATH_PREFIX)
|
|
1034
|
+
|
|
1022
1035
|
|
|
1023
1036
|
@dataclass(frozen=True, slots=True)
|
|
1024
1037
|
class DescriptiveGrade:
|
|
@@ -1032,6 +1045,19 @@ class DescriptiveGrade:
|
|
|
1032
1045
|
kind: GradeKind = GradeKind.CURRENT
|
|
1033
1046
|
href: str | None = field(default=None, repr=False)
|
|
1034
1047
|
|
|
1048
|
+
@property
|
|
1049
|
+
def formative_id(self) -> str | None:
|
|
1050
|
+
"""The formative-assessment ID when `href` is a formative detail link.
|
|
1051
|
+
|
|
1052
|
+
Computed, not a field: canonical notification IDs hash the record's
|
|
1053
|
+
fields, so stored identities stay unchanged.
|
|
1054
|
+
"""
|
|
1055
|
+
# Lazy import: config imports this module.
|
|
1056
|
+
from librus_python_api.config import FORMATIVE_DETAIL_PATH_PREFIX
|
|
1057
|
+
from librus_python_api.markup import detail_id
|
|
1058
|
+
|
|
1059
|
+
return detail_id(self.href, FORMATIVE_DETAIL_PATH_PREFIX)
|
|
1060
|
+
|
|
1035
1061
|
|
|
1036
1062
|
@dataclass(frozen=True, slots=True)
|
|
1037
1063
|
class SchoolAverage:
|
|
@@ -1051,6 +1077,22 @@ class DescriptiveGradeSummary:
|
|
|
1051
1077
|
raw: str = field(repr=False)
|
|
1052
1078
|
|
|
1053
1079
|
|
|
1080
|
+
@dataclass(frozen=True, slots=True)
|
|
1081
|
+
class FormativeGrade:
|
|
1082
|
+
"""One row of the grades page's formative-assessment ("Oceny kształtujące")
|
|
1083
|
+
table. "KARTA SPOSTRZEŻEŃ" (observation card) appears as a subject value.
|
|
1084
|
+
The same item may also appear in the grid with a matching `formative_id`.
|
|
1085
|
+
"""
|
|
1086
|
+
|
|
1087
|
+
subject: str = field(repr=False)
|
|
1088
|
+
text: str = field(repr=False)
|
|
1089
|
+
category: str = field(repr=False)
|
|
1090
|
+
semester: Literal[1, 2]
|
|
1091
|
+
day: date = field(repr=False)
|
|
1092
|
+
assessment_type: str = field(repr=False)
|
|
1093
|
+
detail_id: str = field(repr=False)
|
|
1094
|
+
|
|
1095
|
+
|
|
1054
1096
|
@dataclass(frozen=True, slots=True)
|
|
1055
1097
|
class GradeRecords:
|
|
1056
1098
|
numeric: tuple[NumericGrade, ...] = field(repr=False)
|
|
@@ -1059,6 +1101,7 @@ class GradeRecords:
|
|
|
1059
1101
|
descriptive_summaries: tuple[DescriptiveGradeSummary, ...] = field(
|
|
1060
1102
|
default=(), repr=False
|
|
1061
1103
|
)
|
|
1104
|
+
formative: tuple[FormativeGrade, ...] = field(default=(), repr=False)
|
|
1062
1105
|
|
|
1063
1106
|
|
|
1064
1107
|
@dataclass(frozen=True, slots=True)
|
|
@@ -1078,6 +1121,7 @@ class GradeWindow:
|
|
|
1078
1121
|
descriptive: tuple[DescriptiveGrade, ...] = field(repr=False)
|
|
1079
1122
|
observation: Observation
|
|
1080
1123
|
view: GradeView = GradeView.ALL
|
|
1124
|
+
formative: tuple[FormativeGrade, ...] = field(default=(), repr=False)
|
|
1081
1125
|
|
|
1082
1126
|
|
|
1083
1127
|
@dataclass(frozen=True, slots=True)
|
|
@@ -1307,6 +1307,11 @@ class AccountClient:
|
|
|
1307
1307
|
),
|
|
1308
1308
|
result.observation,
|
|
1309
1309
|
result.view,
|
|
1310
|
+
tuple(
|
|
1311
|
+
g
|
|
1312
|
+
for g in result.records.formative
|
|
1313
|
+
if (start is None or start <= g.day) and (end is None or g.day <= end)
|
|
1314
|
+
),
|
|
1310
1315
|
)
|
|
1311
1316
|
|
|
1312
1317
|
async def attendance(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/_notification_bootstrap.py
RENAMED
|
File without changes
|
{librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/_notification_codec.py
RENAMED
|
File without changes
|
|
File without changes
|
{librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/_windows_filesystem.py
RENAMED
|
File without changes
|
|
File without changes
|
{librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/attachment_routes.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/attendance_frequency.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/completed_lessons.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/message_content.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/modern_messages.py
RENAMED
|
File without changes
|
{librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/notification_models.py
RENAMED
|
File without changes
|
|
File without changes
|
{librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/notification_workflow.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/school_year_archive.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|