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.
Files changed (55) hide show
  1. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/API.md +10 -2
  2. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/CHANGELOG.md +19 -0
  3. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/PKG-INFO +8 -8
  4. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/README.md +7 -7
  5. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/__init__.py +3 -1
  6. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/config.py +12 -0
  7. librus_python_api-1.4.0/src/librus_python_api/formative_grades.py +173 -0
  8. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/grade_records.py +29 -1
  9. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/markup.py +14 -3
  10. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/models.py +44 -0
  11. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/service.py +5 -0
  12. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/.gitignore +0 -0
  13. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/LICENSE +0 -0
  14. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/SECURITY.md +0 -0
  15. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/pyproject.toml +0 -0
  16. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/_notification_bootstrap.py +0 -0
  17. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/_notification_codec.py +0 -0
  18. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/_storage.py +0 -0
  19. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/_windows_filesystem.py +0 -0
  20. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/announcements.py +0 -0
  21. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/attachment_routes.py +0 -0
  22. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/attachments.py +0 -0
  23. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/attendance.py +0 -0
  24. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/attendance_frequency.py +0 -0
  25. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/budget.py +0 -0
  26. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/checkpoint.py +0 -0
  27. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/completed_lessons.py +0 -0
  28. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/detail_fields.py +0 -0
  29. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/diagnostics.py +0 -0
  30. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/exceptions.py +0 -0
  31. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/files.py +0 -0
  32. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/grade_parsers.py +0 -0
  33. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/homework_range.py +0 -0
  34. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/lifecycle.py +0 -0
  35. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/message_content.py +0 -0
  36. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/messages.py +0 -0
  37. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/modern_body.py +0 -0
  38. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/modern_mailbox.py +0 -0
  39. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/modern_messages.py +0 -0
  40. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/notification_models.py +0 -0
  41. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/notification_persistence.py +0 -0
  42. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/notification_workflow.py +0 -0
  43. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/notifications.py +0 -0
  44. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/parsers.py +0 -0
  45. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/parsing.py +0 -0
  46. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/persistence.py +0 -0
  47. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/py.typed +0 -0
  48. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/recipients.py +0 -0
  49. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/scheduler.py +0 -0
  50. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/school_reads.py +0 -0
  51. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/school_year_archive.py +0 -0
  52. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/sending.py +0 -0
  53. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/timetable.py +0 -0
  54. {librus_python_api-1.3.0 → librus_python_api-1.4.0}/src/librus_python_api/transport.py +0 -0
  55. {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. The observation card ("Karta spostrzeżeń") shown on some grades pages is not
233
- read yet.
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.0
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.3.0` adds a read-only school-year archive.
46
- Installed live checks passed for populated and empty
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.3.0
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.3.0-py3-none-any.whl
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 and observation
249
- 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
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.3.0` adds a read-only school-year archive.
20
- Installed live checks passed for populated and empty
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.3.0
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.3.0-py3-none-any.whl
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 and observation
223
- 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
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.3.0"
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) + 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
@@ -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(