librus-python-api 1.2.1__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.2.1 → librus_python_api-1.4.0}/API.md +48 -2
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/CHANGELOG.md +39 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/PKG-INFO +17 -9
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/README.md +16 -8
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/__init__.py +19 -1
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/config.py +49 -0
- librus_python_api-1.4.0/src/librus_python_api/formative_grades.py +173 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/grade_records.py +29 -1
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/markup.py +14 -3
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/models.py +118 -0
- librus_python_api-1.4.0/src/librus_python_api/school_year_archive.py +432 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/service.py +31 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/uv.lock +57 -57
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/.gitignore +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/LICENSE +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/SECURITY.md +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/pyproject.toml +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/_notification_bootstrap.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/_notification_codec.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/_storage.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/_windows_filesystem.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/announcements.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/attachment_routes.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/attachments.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/attendance.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/attendance_frequency.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/budget.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/checkpoint.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/completed_lessons.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/detail_fields.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/diagnostics.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/exceptions.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/files.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/grade_parsers.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/homework_range.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/lifecycle.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/message_content.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/messages.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/modern_body.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/modern_mailbox.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/modern_messages.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/notification_models.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/notification_persistence.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/notification_workflow.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/notifications.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/parsers.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/parsing.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/persistence.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/py.typed +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/recipients.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/scheduler.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/school_reads.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/sending.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/timetable.py +0 -0
- {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/transport.py +0 -0
|
@@ -191,8 +191,54 @@ source)` with an aware UTC timestamp.
|
|
|
191
191
|
selection. It reuses only that view's cache; averages and undated summaries
|
|
192
192
|
remain outside the window.
|
|
193
193
|
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
### School-year archive (1.3.0)
|
|
195
|
+
|
|
196
|
+
`school_year_archive(*, budget=None, max_age_seconds=0.0)` returns
|
|
197
|
+
`SchoolYearArchive(identity, years, achievements, observation)`. It performs one
|
|
198
|
+
read-only GET for all earlier years, with the same per-login cache, coalescing,
|
|
199
|
+
budgets and safe-read session recovery as `announcements()`. There is no year
|
|
200
|
+
selection or chart request. This is separate from the modern message archive.
|
|
201
|
+
|
|
202
|
+
- `years` contains `ArchiveYear` in page order: `class_name`, `school_year`,
|
|
203
|
+
`first_year`, `subjects`, `descriptive`, two raw behaviour fields and `absences`.
|
|
204
|
+
- Each `ArchiveSubject(subject, marks)` has `ArchiveMarks(first_semester,
|
|
205
|
+
second_semester, year_end)`. Empty text and `-` remain distinct. `year_end` is
|
|
206
|
+
not asserted to equal `SubjectGradeSummary.annual`.
|
|
207
|
+
- `ArchiveDescriptive(label, text)` keeps whole-year text, with explicit BR
|
|
208
|
+
boundaries as newlines. Source whitespace is normalized.
|
|
209
|
+
- `behaviour_first_semester` and `behaviour_second_semester_and_year_end` retain
|
|
210
|
+
positional text. Populated behaviour semantics have not been observed.
|
|
211
|
+
- `ArchiveAbsences(unexcused, excused, late)` contains `ArchiveCounts` with the
|
|
212
|
+
same three period names. Counts are rendered integers, never summed or repaired.
|
|
213
|
+
- `achievements` contains `ArchiveAchievement(day, class_name, category, text)`;
|
|
214
|
+
`day` is a civil `date`. A header-only achievements table yields an empty tuple.
|
|
215
|
+
|
|
216
|
+
All archive types are root exports. School text and record containers are omitted
|
|
217
|
+
from reprs; the page's student-name header does not enter archive records.
|
|
218
|
+
Limits: 16 unique consecutive school-year pairs, 128 subject rows, 32 descriptive
|
|
219
|
+
rows, 256 achievements, 1024 characters per short field, 65536 per long text
|
|
220
|
+
cell and 262144 total rendered characters, plus the shared transport/tree bounds.
|
|
221
|
+
|
|
222
|
+
The observed `Brak danych` information notice returns empty `years` and
|
|
223
|
+
`achievements`. Missing tables without that explicit marker still raise `PARSE`;
|
|
224
|
+
an empty notice combined with data tables is rejected. Repeated school years
|
|
225
|
+
(including a class-change layout) are ambiguous and raise `PARSE`. Unknown
|
|
226
|
+
geometry/markup raises `UNSUPPORTED_CAPABILITY`; bounds raise `LIMIT`, never
|
|
227
|
+
partial success. Consumers must handle these states before exposing the read.
|
|
228
|
+
See [archive evidence and limitations](contracts/grades.md#school-year-archive-130).
|
|
229
|
+
|
|
230
|
+
The observed grade-detail page added no fields beyond the list tooltip, so
|
|
231
|
+
there is no separate per-grade detail request. This finding is limited to the
|
|
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).
|
|
196
242
|
|
|
197
243
|
## Attendance
|
|
198
244
|
|
|
@@ -1,5 +1,44 @@
|
|
|
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
|
+
|
|
22
|
+
## 1.3.0 (2026-10-07) - School-year archive read
|
|
23
|
+
|
|
24
|
+
Compatible minor release. Existing calls and results are unchanged.
|
|
25
|
+
|
|
26
|
+
- Add `AccountClient.school_year_archive()` and frozen per-year records for
|
|
27
|
+
subject marks, descriptive text, raw behaviour cells, absence counts and
|
|
28
|
+
dated achievements. One read-only GET covers all earlier years without year
|
|
29
|
+
selection, chart requests or fetching grade-detail links.
|
|
30
|
+
- Apply the shared account-isolated cache, coalescing, recovery and budgets.
|
|
31
|
+
Reject ambiguous tables, unknown markup and exceeded bounds without partial
|
|
32
|
+
output. Preserve empty marks and school-provided year-end values.
|
|
33
|
+
- Add guarded weekly archive coverage with redacted year counts. Older pinned
|
|
34
|
+
libraries without the method skip this check. Installed live checks passed
|
|
35
|
+
on two parent logins, covering populated and explicit empty archives with
|
|
36
|
+
unchanged notification counters. Broader school-layout support is unqualified.
|
|
37
|
+
- Recognize the observed `Brak danych` empty notice and the malformed HTML
|
|
38
|
+
chart container after the achievements footer. Nested tabular data still fails.
|
|
39
|
+
- Record the #40 grade-detail finding: the observed page added no fields beyond
|
|
40
|
+
the list tooltip, so no separate grade-detail read is introduced.
|
|
41
|
+
|
|
3
42
|
## 1.2.1 (2026-10-06) - Live fixes for archive listing and teacher subjects
|
|
4
43
|
|
|
5
44
|
Compatible patch release found by the new pre-publication live check. 1.1.0 and
|
|
@@ -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
|
|
@@ -26,6 +26,8 @@ Description-Content-Type: text/markdown
|
|
|
26
26
|
|
|
27
27
|
# librus-python-api
|
|
28
28
|
|
|
29
|
+
[](https://github.com/krzysztofbury/librus-python-api/actions/workflows/live-check.yml)
|
|
30
|
+
|
|
29
31
|
Read grades, attendance, homework, timetables and messages from **Librus Synergia**
|
|
30
32
|
in Python. This independent, asynchronous client handles login, session recovery,
|
|
31
33
|
pagination and request limits, returning typed Python objects rather than HTML.
|
|
@@ -40,9 +42,10 @@ private ACLs. Windows installations include `pywin32` and `tzdata` automatically
|
|
|
40
42
|
Network paths, reparse-point paths and unsafe storage permissions fail closed.
|
|
41
43
|
See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
|
|
42
44
|
|
|
43
|
-
**Status:** `1.0
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
The stable 1.x API does not guarantee every school layout. School features depend
|
|
46
49
|
on what each account can access. See the [compatibility policy](API.md#compatibility-policy).
|
|
47
50
|
See [limitations](#supported-features-and-limitations) below.
|
|
48
51
|
|
|
@@ -51,7 +54,7 @@ See [limitations](#supported-features-and-limitations) below.
|
|
|
51
54
|
Install the exact release from PyPI after its gated publication completes:
|
|
52
55
|
|
|
53
56
|
```sh
|
|
54
|
-
python -m pip install librus-python-api==1.
|
|
57
|
+
python -m pip install librus-python-api==1.4.0
|
|
55
58
|
```
|
|
56
59
|
|
|
57
60
|
Pin the exact version qualified by your application. Before publication, use a
|
|
@@ -67,7 +70,7 @@ python -m pip install .
|
|
|
67
70
|
To install a locally built wheel instead:
|
|
68
71
|
|
|
69
72
|
```sh
|
|
70
|
-
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
|
|
71
74
|
```
|
|
72
75
|
|
|
73
76
|
No CLI or background process is installed: import the library in your own program.
|
|
@@ -148,6 +151,11 @@ attendance = await client.attendance_window(today.replace(day=1), today)
|
|
|
148
151
|
|
|
149
152
|
# Permit reuse of this account's cached result for up to 60 seconds.
|
|
150
153
|
announcements = await client.announcements(max_age_seconds=60)
|
|
154
|
+
|
|
155
|
+
# Earlier school years, separate from the message archive (1.3.0).
|
|
156
|
+
archive = await client.school_year_archive()
|
|
157
|
+
for year in archive.years:
|
|
158
|
+
print(year.school_year, year.subjects)
|
|
151
159
|
```
|
|
152
160
|
|
|
153
161
|
Collections expose named record tuples, for example `homework.items`, rather than
|
|
@@ -233,12 +241,12 @@ removed in 0.7.
|
|
|
233
241
|
|
|
234
242
|
| Area | Available |
|
|
235
243
|
| --- | --- |
|
|
236
|
-
| School data | Profile, grades, attendance, timetable, announcements, agenda, homework and completed lessons |
|
|
244
|
+
| School data | Profile, grades, school-year archive, attendance, timetable, announcements, agenda, homework and completed lessons |
|
|
237
245
|
| Communication | Legacy/modern message lists and content, recipient discovery, bounded attachment streams and explicit sending |
|
|
238
246
|
| Application workflows | Shared multi-account limits, caching, notification checkpoints, optional durable send/notification stores |
|
|
239
247
|
|
|
240
|
-
Completed lessons may be disabled by the school. Behaviour notes
|
|
241
|
-
|
|
248
|
+
Completed lessons may be disabled by the school. Behaviour notes are not
|
|
249
|
+
implemented. Some recipient, archive and receipt layouts remain
|
|
242
250
|
unqualified; backend acceptance is not proof of delivery. Tests cover supported
|
|
243
251
|
contracts, not every school or role. Detailed coverage is in the
|
|
244
252
|
[verification log][verification] and [roadmap][roadmap].
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# librus-python-api
|
|
2
2
|
|
|
3
|
+
[](https://github.com/krzysztofbury/librus-python-api/actions/workflows/live-check.yml)
|
|
4
|
+
|
|
3
5
|
Read grades, attendance, homework, timetables and messages from **Librus Synergia**
|
|
4
6
|
in Python. This independent, asynchronous client handles login, session recovery,
|
|
5
7
|
pagination and request limits, returning typed Python objects rather than HTML.
|
|
@@ -14,9 +16,10 @@ private ACLs. Windows installations include `pywin32` and `tzdata` automatically
|
|
|
14
16
|
Network paths, reparse-point paths and unsafe storage permissions fail closed.
|
|
15
17
|
See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
|
|
16
18
|
|
|
17
|
-
**Status:** `1.0
|
|
18
|
-
|
|
19
|
-
|
|
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
|
+
The stable 1.x API does not guarantee every school layout. School features depend
|
|
20
23
|
on what each account can access. See the [compatibility policy](API.md#compatibility-policy).
|
|
21
24
|
See [limitations](#supported-features-and-limitations) below.
|
|
22
25
|
|
|
@@ -25,7 +28,7 @@ See [limitations](#supported-features-and-limitations) below.
|
|
|
25
28
|
Install the exact release from PyPI after its gated publication completes:
|
|
26
29
|
|
|
27
30
|
```sh
|
|
28
|
-
python -m pip install librus-python-api==1.
|
|
31
|
+
python -m pip install librus-python-api==1.4.0
|
|
29
32
|
```
|
|
30
33
|
|
|
31
34
|
Pin the exact version qualified by your application. Before publication, use a
|
|
@@ -41,7 +44,7 @@ python -m pip install .
|
|
|
41
44
|
To install a locally built wheel instead:
|
|
42
45
|
|
|
43
46
|
```sh
|
|
44
|
-
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
|
|
45
48
|
```
|
|
46
49
|
|
|
47
50
|
No CLI or background process is installed: import the library in your own program.
|
|
@@ -122,6 +125,11 @@ attendance = await client.attendance_window(today.replace(day=1), today)
|
|
|
122
125
|
|
|
123
126
|
# Permit reuse of this account's cached result for up to 60 seconds.
|
|
124
127
|
announcements = await client.announcements(max_age_seconds=60)
|
|
128
|
+
|
|
129
|
+
# Earlier school years, separate from the message archive (1.3.0).
|
|
130
|
+
archive = await client.school_year_archive()
|
|
131
|
+
for year in archive.years:
|
|
132
|
+
print(year.school_year, year.subjects)
|
|
125
133
|
```
|
|
126
134
|
|
|
127
135
|
Collections expose named record tuples, for example `homework.items`, rather than
|
|
@@ -207,12 +215,12 @@ removed in 0.7.
|
|
|
207
215
|
|
|
208
216
|
| Area | Available |
|
|
209
217
|
| --- | --- |
|
|
210
|
-
| School data | Profile, grades, attendance, timetable, announcements, agenda, homework and completed lessons |
|
|
218
|
+
| School data | Profile, grades, school-year archive, attendance, timetable, announcements, agenda, homework and completed lessons |
|
|
211
219
|
| Communication | Legacy/modern message lists and content, recipient discovery, bounded attachment streams and explicit sending |
|
|
212
220
|
| Application workflows | Shared multi-account limits, caching, notification checkpoints, optional durable send/notification stores |
|
|
213
221
|
|
|
214
|
-
Completed lessons may be disabled by the school. Behaviour notes
|
|
215
|
-
|
|
222
|
+
Completed lessons may be disabled by the school. Behaviour notes are not
|
|
223
|
+
implemented. Some recipient, archive and receipt layouts remain
|
|
216
224
|
unqualified; backend acceptance is not proof of delivery. Tests cover supported
|
|
217
225
|
contracts, not every school or role. Detailed coverage is in the
|
|
218
226
|
[verification log][verification] and [roadmap][roadmap].
|
|
@@ -16,6 +16,13 @@ from librus_python_api.models import (
|
|
|
16
16
|
AgendaEvent,
|
|
17
17
|
Announcement,
|
|
18
18
|
Announcements,
|
|
19
|
+
ArchiveAbsences,
|
|
20
|
+
ArchiveAchievement,
|
|
21
|
+
ArchiveCounts,
|
|
22
|
+
ArchiveDescriptive,
|
|
23
|
+
ArchiveMarks,
|
|
24
|
+
ArchiveSubject,
|
|
25
|
+
ArchiveYear,
|
|
19
26
|
AttachmentHeaders,
|
|
20
27
|
AttachmentMetadata,
|
|
21
28
|
Attendance,
|
|
@@ -37,6 +44,7 @@ from librus_python_api.models import (
|
|
|
37
44
|
DetailField,
|
|
38
45
|
DetailFieldKey,
|
|
39
46
|
FinalGrades,
|
|
47
|
+
FormativeGrade,
|
|
40
48
|
FrequencyMeasure,
|
|
41
49
|
GatewayAttendance,
|
|
42
50
|
GatewayAttendanceRecord,
|
|
@@ -110,6 +118,7 @@ from librus_python_api.models import (
|
|
|
110
118
|
SchoolAverage,
|
|
111
119
|
SchoolDetail,
|
|
112
120
|
SchoolReference,
|
|
121
|
+
SchoolYearArchive,
|
|
113
122
|
SendResult,
|
|
114
123
|
SendStatus,
|
|
115
124
|
SendSubmission,
|
|
@@ -127,9 +136,17 @@ from librus_python_api.models import (
|
|
|
127
136
|
from librus_python_api.sending import SendAttempt
|
|
128
137
|
from librus_python_api.service import AccountClient, LibrusService
|
|
129
138
|
|
|
130
|
-
__version__ = "1.
|
|
139
|
+
__version__ = "1.4.0"
|
|
131
140
|
|
|
132
141
|
__all__ = [
|
|
142
|
+
"ArchiveAbsences",
|
|
143
|
+
"ArchiveAchievement",
|
|
144
|
+
"ArchiveCounts",
|
|
145
|
+
"ArchiveDescriptive",
|
|
146
|
+
"ArchiveMarks",
|
|
147
|
+
"ArchiveSubject",
|
|
148
|
+
"ArchiveYear",
|
|
149
|
+
"SchoolYearArchive",
|
|
133
150
|
"AccountContext",
|
|
134
151
|
"MessagingBackend",
|
|
135
152
|
"ModernAccountData",
|
|
@@ -235,6 +252,7 @@ __all__ = [
|
|
|
235
252
|
"DescriptiveGrade",
|
|
236
253
|
"DescriptiveGradeSummary",
|
|
237
254
|
"FinalGrades",
|
|
255
|
+
"FormativeGrade",
|
|
238
256
|
"GradeKind",
|
|
239
257
|
"GradeRecords",
|
|
240
258
|
"Grades",
|
|
@@ -505,6 +505,14 @@ ENDPOINTS: Mapping[str, Endpoint] = MappingProxyType(
|
|
|
505
505
|
True,
|
|
506
506
|
Evidence.INDEPENDENTLY_OBSERVED,
|
|
507
507
|
),
|
|
508
|
+
Endpoint(
|
|
509
|
+
"school_year_archive",
|
|
510
|
+
"GET",
|
|
511
|
+
"/archiwum",
|
|
512
|
+
SideEffect.NONE,
|
|
513
|
+
True,
|
|
514
|
+
Evidence.INDEPENDENTLY_OBSERVED,
|
|
515
|
+
),
|
|
508
516
|
Endpoint(
|
|
509
517
|
"agenda",
|
|
510
518
|
"POST",
|
|
@@ -713,6 +721,18 @@ ATTENDANCE_MAX_LESSONS = 4096
|
|
|
713
721
|
ATTENDANCE_MAX_SUBJECTS = 1024
|
|
714
722
|
# Collection recognition and detail retrieval share this central path family.
|
|
715
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
|
|
716
736
|
ATTENDANCE_DETAIL_MAX_FIELDS = 32
|
|
717
737
|
ATTENDANCE_METADATA_CACHE_SIZE = 256
|
|
718
738
|
ATTENDANCE_RESULT_CACHE_SIZE = 64
|
|
@@ -722,6 +742,35 @@ ANNOUNCEMENT_MAX_ITEMS = 256
|
|
|
722
742
|
ANNOUNCEMENT_MAX_FIELD_LENGTH = 1024
|
|
723
743
|
ANNOUNCEMENT_MAX_CONTENT_LENGTH = 65536
|
|
724
744
|
ANNOUNCEMENT_MAX_TOTAL_TEXT_LENGTH = 262144
|
|
745
|
+
|
|
746
|
+
# School-year archive (/archiwum): labels as rendered upstream, including the
|
|
747
|
+
# absence spellings. Matching is case-insensitive and exact otherwise.
|
|
748
|
+
ARCHIVE_TABLE_CLASSES = frozenset({"decorated"})
|
|
749
|
+
ARCHIVE_ACHIEVEMENT_TABLE_CLASSES = frozenset({"decorated", "big", "center"})
|
|
750
|
+
ARCHIVE_YEAR_PATTERN = (
|
|
751
|
+
r"Klasa: (?P<class_name>\S(?:.*\S)?) Rok: (?P<first>[0-9]{4})/(?P<second>[0-9]{4})"
|
|
752
|
+
)
|
|
753
|
+
ARCHIVE_PERIOD_LABELS = ("okres 1", "okres 2", "koniec roku")
|
|
754
|
+
ARCHIVE_BEHAVIOUR_HEADING = "zachowanie"
|
|
755
|
+
ARCHIVE_ABSENCES_HEADING = "nieobecności"
|
|
756
|
+
ARCHIVE_ABSENCE_LABELS = MappingProxyType(
|
|
757
|
+
{
|
|
758
|
+
"nieusprawiedlione": "unexcused",
|
|
759
|
+
"usprawiedlione": "excused",
|
|
760
|
+
"spóźnienia": "late",
|
|
761
|
+
}
|
|
762
|
+
)
|
|
763
|
+
ARCHIVE_ACHIEVEMENT_HEADERS = ("data", "klasa", "kategoria", "osiągnięcie")
|
|
764
|
+
ARCHIVE_YEAR_HEADER_TAGS = frozenset({"span", "b"})
|
|
765
|
+
ARCHIVE_MAX_YEARS = 16
|
|
766
|
+
ARCHIVE_MAX_SUBJECTS = 128
|
|
767
|
+
ARCHIVE_MAX_DESCRIPTIVE = 32
|
|
768
|
+
ARCHIVE_MAX_ACHIEVEMENTS = 256
|
|
769
|
+
ARCHIVE_MAX_TEXT_LENGTH = 65536
|
|
770
|
+
ARCHIVE_MAX_TOTAL_TEXT_LENGTH = 262144
|
|
771
|
+
ARCHIVE_MAX_YEAR_HEADER_LENGTH = 1024 + len("Klasa: Rok: 0000/0000")
|
|
772
|
+
ARCHIVE_EMPTY_TITLE = "brak danych"
|
|
773
|
+
ARCHIVE_EMPTY_BOX_CLASSES = frozenset({"warning-box", "information", "medium"})
|
|
725
774
|
ANNOUNCEMENT_TABLE_CLASSES = frozenset(
|
|
726
775
|
{"decorated", "big", "center", "printable", "margin-top"}
|
|
727
776
|
)
|
|
@@ -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
|