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.
Files changed (55) hide show
  1. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/API.md +48 -2
  2. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/CHANGELOG.md +39 -0
  3. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/PKG-INFO +17 -9
  4. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/README.md +16 -8
  5. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/__init__.py +19 -1
  6. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/config.py +49 -0
  7. librus_python_api-1.4.0/src/librus_python_api/formative_grades.py +173 -0
  8. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/grade_records.py +29 -1
  9. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/markup.py +14 -3
  10. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/models.py +118 -0
  11. librus_python_api-1.4.0/src/librus_python_api/school_year_archive.py +432 -0
  12. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/service.py +31 -0
  13. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/uv.lock +57 -57
  14. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/.gitignore +0 -0
  15. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/LICENSE +0 -0
  16. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/SECURITY.md +0 -0
  17. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/pyproject.toml +0 -0
  18. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/_notification_bootstrap.py +0 -0
  19. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/_notification_codec.py +0 -0
  20. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/_storage.py +0 -0
  21. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/_windows_filesystem.py +0 -0
  22. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/announcements.py +0 -0
  23. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/attachment_routes.py +0 -0
  24. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/attachments.py +0 -0
  25. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/attendance.py +0 -0
  26. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/attendance_frequency.py +0 -0
  27. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/budget.py +0 -0
  28. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/checkpoint.py +0 -0
  29. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/completed_lessons.py +0 -0
  30. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/detail_fields.py +0 -0
  31. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/diagnostics.py +0 -0
  32. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/exceptions.py +0 -0
  33. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/files.py +0 -0
  34. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/grade_parsers.py +0 -0
  35. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/homework_range.py +0 -0
  36. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/lifecycle.py +0 -0
  37. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/message_content.py +0 -0
  38. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/messages.py +0 -0
  39. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/modern_body.py +0 -0
  40. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/modern_mailbox.py +0 -0
  41. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/modern_messages.py +0 -0
  42. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/notification_models.py +0 -0
  43. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/notification_persistence.py +0 -0
  44. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/notification_workflow.py +0 -0
  45. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/notifications.py +0 -0
  46. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/parsers.py +0 -0
  47. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/parsing.py +0 -0
  48. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/persistence.py +0 -0
  49. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/py.typed +0 -0
  50. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/recipients.py +0 -0
  51. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/scheduler.py +0 -0
  52. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/school_reads.py +0 -0
  53. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/sending.py +0 -0
  54. {librus_python_api-1.2.1 → librus_python_api-1.4.0}/src/librus_python_api/timetable.py +0 -0
  55. {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
- The observation card ("Karta spostrzeżeń") shown on some grades pages is not
195
- read yet.
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.2.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
+ [![Weekly live check](https://github.com/krzysztofbury/librus-python-api/actions/workflows/live-check.yml/badge.svg?branch=main)](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.x` freezes the documented native library API. It does not claim
44
- completed MCP cutover, production-state migration or universal live-school
45
- compatibility. Those consumer/live gates remain separate. School features depend
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.2.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.2.1-py3-none-any.whl
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 and observation
241
- cards are not implemented. Some recipient, archive and receipt layouts remain
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
+ [![Weekly live check](https://github.com/krzysztofbury/librus-python-api/actions/workflows/live-check.yml/badge.svg?branch=main)](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.x` freezes the documented native library API. It does not claim
18
- completed MCP cutover, production-state migration or universal live-school
19
- compatibility. Those consumer/live gates remain separate. School features depend
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.2.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.2.1-py3-none-any.whl
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 and observation
215
- cards are not implemented. Some recipient, archive and receipt layouts remain
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.2.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) + len(self.descriptive) + len(self.descriptive_summaries)
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(match[2])
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(ATTENDANCE_DETAIL_PATH_PREFIX):
155
+ if not target.path.startswith(prefix):
145
156
  return None
146
- identifier = target.path[len(ATTENDANCE_DETAIL_PATH_PREFIX) :]
157
+ identifier = target.path[len(prefix) :]
147
158
  return identifier if re.fullmatch(r"[0-9]{1,64}", identifier) else None