librus-python-api 1.6.4__tar.gz → 1.7.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 (56) hide show
  1. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/API.md +9 -0
  2. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/CHANGELOG.md +26 -0
  3. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/PKG-INFO +7 -6
  4. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/README.md +6 -5
  5. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/pyproject.toml +5 -2
  6. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/__init__.py +1 -1
  7. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/config.py +10 -0
  8. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/grade_parsers.py +127 -19
  9. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/grade_records.py +77 -17
  10. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/models.py +16 -0
  11. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/uv.lock +14 -0
  12. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/.gitignore +0 -0
  13. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/LICENSE +0 -0
  14. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/SECURITY.md +0 -0
  15. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/_notification_bootstrap.py +0 -0
  16. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/_notification_codec.py +0 -0
  17. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/_storage.py +0 -0
  18. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/_windows_filesystem.py +0 -0
  19. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/announcements.py +0 -0
  20. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/attachment_routes.py +0 -0
  21. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/attachments.py +0 -0
  22. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/attendance.py +0 -0
  23. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/attendance_frequency.py +0 -0
  24. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/budget.py +0 -0
  25. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/checkpoint.py +0 -0
  26. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/class_free_days.py +0 -0
  27. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/completed_lessons.py +0 -0
  28. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/detail_fields.py +0 -0
  29. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/diagnostics.py +0 -0
  30. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/exceptions.py +0 -0
  31. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/files.py +0 -0
  32. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/formative_grades.py +0 -0
  33. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/homework_range.py +0 -0
  34. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/lifecycle.py +0 -0
  35. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/markup.py +0 -0
  36. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/message_content.py +0 -0
  37. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/messages.py +0 -0
  38. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/modern_body.py +0 -0
  39. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/modern_mailbox.py +0 -0
  40. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/modern_messages.py +0 -0
  41. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/notification_models.py +0 -0
  42. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/notification_persistence.py +0 -0
  43. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/notification_workflow.py +0 -0
  44. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/notifications.py +0 -0
  45. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/parsers.py +0 -0
  46. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/parsing.py +0 -0
  47. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/persistence.py +0 -0
  48. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/py.typed +0 -0
  49. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/recipients.py +0 -0
  50. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/scheduler.py +0 -0
  51. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/school_reads.py +0 -0
  52. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/school_year_archive.py +0 -0
  53. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/sending.py +0 -0
  54. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/service.py +0 -0
  55. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/timetable.py +0 -0
  56. {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/transport.py +0 -0
@@ -240,6 +240,15 @@ value. The same items stay in `numeric`/`descriptive` unchanged; their
240
240
  identities are unchanged) links them to `detail_id`. A page without the
241
241
  section returns `()`. See [formative grades](contracts/grades.md#formative-grades-and-the-observation-card-140).
242
242
 
243
+ Point grades (1.7.0) come from the "Oceny punktowe" table that point-grade
244
+ schools show beside, or instead of, the ordinary table. Its entries are
245
+ `NumericGrade`s in `numeric` whose computed `point_grade_id` property holds the
246
+ point-grade detail ID; `raw` is then a score on the scale named by `category`,
247
+ such as `(0-100)`, not a grade symbol. The table's "Wyn." period results are
248
+ `GradeRecords.point_results` (`SchoolAverage`s on the score scale, never mixed
249
+ into `averages`), and a point table with titled summary columns adds its
250
+ subjects to `final_grades()`. See [point grades](contracts/grades.md#point-grades-170).
251
+
243
252
  ## Attendance
244
253
 
245
254
  - `attendance(view=AttendanceView.ALL)` returns `Attendance` with
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.0 (2026-10-27) - Point grades
4
+
5
+ Reads the "Oceny punktowe" table that point-grade schools show on the grades
6
+ page. Its presence made `grades()`, `grades_window()` and `final_grades()` fail
7
+ with `ParseError` for those accounts. The layout was established offline from
8
+ a relayed screenshot and a public issue report; live qualification on a
9
+ point-grade account is pending.
10
+
11
+ - Grades: point entries are returned in `numeric` with their raw score, scale
12
+ category and tooltip fields. The new computed `NumericGrade.point_grade_id`
13
+ property identifies them. It is not a field, so notification identities are
14
+ unchanged. A current point entry without its point-grade detail link fails
15
+ instead of passing as a grade symbol.
16
+ - Grades: the new `GradeRecords.point_results` holds the "Wyn." period
17
+ results on the score scale. They are never merged into `averages`, which
18
+ stay the ordinary grade-scale means.
19
+ - Grades: a period grade that both tables show is one record.
20
+ - Final grades: a point table with titled summary columns (midterm, predicted
21
+ or annual) adds its subjects; a missing column is unavailable. For a subject
22
+ in both tables, each value is merged on its own: an assigned value fills an
23
+ unassigned one, equal values are one value and two different assigned values
24
+ fail.
25
+ - More than one point table, duplicate point subjects, misaligned rows and an
26
+ ordinary grade table without an annual column fail instead of returning
27
+ partial output.
28
+
3
29
  ## 1.6.4 (2026-10-26) - Message body rendering security fix
4
30
 
5
31
  Fixes a denial of service in message body rendering. Public types, signatures
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: librus-python-api
3
- Version: 1.6.4
3
+ Version: 1.7.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,10 @@ private ACLs. Windows installations include `pywin32` and `tzdata` automatically
42
42
  Network paths, reparse-point paths and unsafe storage permissions fail closed.
43
43
  See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
44
44
 
45
- **Status:** `1.6.4` renders message bodies in linear time, so a crafted body can
46
- no longer stall a client, and keeps the 1.6.2 completed-lesson and grade layouts,
47
- including behaviour points, which are validated and not reported as grades.
45
+ **Status:** `1.7.0` reads point grades ("Oceny punktowe"), which made both grade
46
+ reads fail at point-grade schools. It keeps the 1.6.4 linear-time message body
47
+ rendering and the 1.6.2 completed-lesson and grade layouts, including behaviour
48
+ points, which are validated and not reported as grades.
48
49
  Class free days, typed capability outcomes and the live-check freshness watchdog
49
50
  remain available. See [verification](VERIFICATION.md).
50
51
  The stable 1.x API does not guarantee every school layout. School features depend
@@ -56,7 +57,7 @@ See [limitations](#supported-features-and-limitations) below.
56
57
  Install the exact release from PyPI after its gated publication completes:
57
58
 
58
59
  ```sh
59
- python -m pip install librus-python-api==1.6.4
60
+ python -m pip install librus-python-api==1.7.0
60
61
  ```
61
62
 
62
63
  Pin the exact version qualified by your application. Before publication, use a
@@ -72,7 +73,7 @@ python -m pip install .
72
73
  To install a locally built wheel instead:
73
74
 
74
75
  ```sh
75
- python -m pip install ./dist/librus_python_api-1.6.4-py3-none-any.whl
76
+ python -m pip install ./dist/librus_python_api-1.7.0-py3-none-any.whl
76
77
  ```
77
78
 
78
79
  No CLI or background process is installed: import the library in your own program.
@@ -16,9 +16,10 @@ private ACLs. Windows installations include `pywin32` and `tzdata` automatically
16
16
  Network paths, reparse-point paths and unsafe storage permissions fail closed.
17
17
  See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
18
18
 
19
- **Status:** `1.6.4` renders message bodies in linear time, so a crafted body can
20
- no longer stall a client, and keeps the 1.6.2 completed-lesson and grade layouts,
21
- including behaviour points, which are validated and not reported as grades.
19
+ **Status:** `1.7.0` reads point grades ("Oceny punktowe"), which made both grade
20
+ reads fail at point-grade schools. It keeps the 1.6.4 linear-time message body
21
+ rendering and the 1.6.2 completed-lesson and grade layouts, including behaviour
22
+ points, which are validated and not reported as grades.
22
23
  Class free days, typed capability outcomes and the live-check freshness watchdog
23
24
  remain available. See [verification](VERIFICATION.md).
24
25
  The stable 1.x API does not guarantee every school layout. School features depend
@@ -30,7 +31,7 @@ See [limitations](#supported-features-and-limitations) below.
30
31
  Install the exact release from PyPI after its gated publication completes:
31
32
 
32
33
  ```sh
33
- python -m pip install librus-python-api==1.6.4
34
+ python -m pip install librus-python-api==1.7.0
34
35
  ```
35
36
 
36
37
  Pin the exact version qualified by your application. Before publication, use a
@@ -46,7 +47,7 @@ python -m pip install .
46
47
  To install a locally built wheel instead:
47
48
 
48
49
  ```sh
49
- python -m pip install ./dist/librus_python_api-1.6.4-py3-none-any.whl
50
+ python -m pip install ./dist/librus_python_api-1.7.0-py3-none-any.whl
50
51
  ```
51
52
 
52
53
  No CLI or background process is installed: import the library in your own program.
@@ -41,6 +41,7 @@ dev = [
41
41
  "types-pywin32>=312,<400",
42
42
  "pre-commit>=4.6,<5",
43
43
  "pip-audit>=2.10,<3",
44
+ "pytest-timeout>=2.3,<3",
44
45
  ]
45
46
 
46
47
  [tool.hatch.version]
@@ -56,8 +57,10 @@ include = ["/src", "/pyproject.toml", "/uv.lock", "/README.md", "/SECURITY.md",
56
57
  testpaths = ["tests"]
57
58
  pythonpath = ["."]
58
59
  addopts = "--import-mode=importlib -ra -m 'not performance'"
59
- # Dump every thread's stack if one test runs this long, so a hang names itself.
60
- faulthandler_timeout = 300
60
+ # A hung test dumps every thread's stack, then fails with its id instead of
61
+ # holding the job until its timeout (#64, #98). The slowest test takes 9 s.
62
+ faulthandler_timeout = 100
63
+ timeout = 120
61
64
  markers = [
62
65
  "performance: opt-in hardware-sensitive resource measurements",
63
66
  ]
@@ -138,7 +138,7 @@ from librus_python_api.models import (
138
138
  from librus_python_api.sending import SendAttempt
139
139
  from librus_python_api.service import AccountClient, LibrusService
140
140
 
141
- __version__ = "1.6.4"
141
+ __version__ = "1.7.0"
142
142
 
143
143
  __all__ = [
144
144
  "ArchiveAbsences",
@@ -659,6 +659,9 @@ GRADE_MAX_SUBJECTS = 128
659
659
  GRADE_MAX_VALUE_LENGTH = 1024
660
660
  GRADE_MERGED_SUBJECTS = frozenset({"Zachowanie"})
661
661
  GRADE_INLINE_DETAIL_LABEL = "Ocena"
662
+ # A point-grade ("Oceny punktowe") table marks each subject row's expander image
663
+ # with this ID; ordinary grade rows use `przedmioty_<id>_node`.
664
+ GRADE_POINT_ROW_NODE = re.compile(r"przedmioty_OP_[0-9A-Za-z_]{1,64}_node")
662
665
  GRADE_VIEW_FIELDS = MappingProxyType(
663
666
  {
664
667
  "all": "zmiany_logowanie_wszystkie",
@@ -671,6 +674,11 @@ GRADE_EMPTY_MARKERS = frozenset({"", "-", "Brak ocen"})
671
674
  GRADE_AVERAGE_HEADERS = MappingProxyType(
672
675
  {"Średnia ocen": 1, "Średnia ocen z drugiego okresu": 2, "Średnia roczna": 0}
673
676
  )
677
+ # Point tables label their period results by visible text, not by title. Only
678
+ # "Wyn.I" was observed (owner-relayed screenshot, 2026-10-27); the others
679
+ # follow the same period naming as the ordinary I/II/R summary columns.
680
+ GRADE_POINT_RESULT_HEADER = re.compile(r"Wyn\.\s?(I|II|R)")
681
+ GRADE_POINT_RESULT_PERIODS = MappingProxyType({"I": 1, "II": 2, "R": 0})
674
682
  GRADE_MAX_RECORDS = 2048
675
683
  GRADE_MAX_METADATA_LENGTH = 8192
676
684
  GRADE_MAX_METADATA_FIELDS = 32
@@ -733,6 +741,8 @@ ATTENDANCE_DETAIL_PATH_PREFIX = "/przegladaj_nb/szczegoly/"
733
741
  FORMATIVE_DETAIL_PATH_PREFIX = "/przegladaj_oceny/szczegoly/ksztaltujace/"
734
742
  # Recognition only: behaviour-point detail links are classified, never requested.
735
743
  BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX = "/przegladaj_oceny/zachowanie_szczegoly/"
744
+ # Recognition only: point-grade detail links are classified, never requested.
745
+ POINT_GRADE_DETAIL_PATH_PREFIX = "/przegladaj_oceny_punktowe/szczegoly/"
736
746
  FORMATIVE_HEADERS = (
737
747
  "przedmiot",
738
748
  "ocena kształtująca",
@@ -8,11 +8,14 @@ from lxml import html
8
8
  from librus_python_api import markup
9
9
  from librus_python_api.config import (
10
10
  GRADE_BODY_PREFIX_COLUMNS,
11
+ GRADE_CURRENT_HEADER,
12
+ GRADE_EMPTY_MARKERS,
11
13
  GRADE_INLINE_DETAIL_LABEL,
12
14
  GRADE_MAX_COLUMNS,
13
15
  GRADE_MAX_SUBJECTS,
14
16
  GRADE_MAX_VALUE_LENGTH,
15
17
  GRADE_MERGED_SUBJECTS,
18
+ GRADE_POINT_ROW_NODE,
16
19
  GRADE_SUMMARY_HEADERS,
17
20
  )
18
21
  from librus_python_api.exceptions import ErrorKind, LibrusError
@@ -48,13 +51,53 @@ def _header(row: html.HtmlElement) -> tuple[dict[str, int], int]:
48
51
  return fields, position
49
52
 
50
53
 
51
- def _locate(
54
+ def is_point_table(table: html.HtmlElement) -> bool:
55
+ """A grade table whose subject rows carry point-grade expander images."""
56
+ if not {"decorated", "stretch"} <= set(table.get("class", "").split()):
57
+ return False
58
+ return any(
59
+ GRADE_POINT_ROW_NODE.fullmatch(image.get("id", ""))
60
+ for row in markup.rows(table)
61
+ for cell in markup.cells(row)[:1]
62
+ for image in cell.iter("img")
63
+ )
64
+
65
+
66
+ def _current_header(table: html.HtmlElement) -> bool:
67
+ """A grade table: decorated, with a current-grade header row."""
68
+ return {"decorated", "stretch"} <= set(table.get("class", "").split()) and any(
69
+ next(row.iterancestors("thead"), None) is not None
70
+ and any(markup.text(cell) == GRADE_CURRENT_HEADER for cell in markup.cells(row))
71
+ for row in markup.rows(table)
72
+ )
73
+
74
+
75
+ def point_layout(table: html.HtmlElement) -> tuple[dict[str, int], int]:
76
+ """Summary fields and width from the point table's current-grade header row.
77
+
78
+ Point tables are not required to have an annual column, so the row with
79
+ the two current-grade headers defines the body alignment instead.
80
+ """
81
+ headers = [
82
+ row
83
+ for row in markup.rows(table)
84
+ if next(row.iterancestors("thead"), None) is not None
85
+ and any(markup.text(cell) == GRADE_CURRENT_HEADER for cell in markup.cells(row))
86
+ ]
87
+ if len(headers) != 1:
88
+ raise LibrusError(ErrorKind.PARSE)
89
+ return _header(headers[0])
90
+
91
+
92
+ def _candidates(
52
93
  document: html.HtmlElement,
53
- ) -> tuple[html.HtmlElement, dict[str, int], int]:
94
+ ) -> list[tuple[html.HtmlElement, dict[str, int], int]]:
54
95
  candidates: list[tuple[html.HtmlElement, dict[str, int], int]] = []
55
96
  for table in document.iter("table"):
56
97
  if not {"decorated", "stretch"} <= set(table.get("class", "").split()):
57
98
  continue
99
+ if is_point_table(table):
100
+ continue
58
101
  for row in markup.rows(table):
59
102
  if next(row.iterancestors("thead"), None) is None:
60
103
  continue
@@ -64,6 +107,13 @@ def _locate(
64
107
  continue
65
108
  fields, width = _header(row)
66
109
  candidates.append((table, fields, width))
110
+ return candidates
111
+
112
+
113
+ def _locate(
114
+ document: html.HtmlElement,
115
+ ) -> tuple[html.HtmlElement, dict[str, int], int]:
116
+ candidates = _candidates(document)
67
117
  if len(candidates) != 1:
68
118
  raise LibrusError(ErrorKind.PARSE)
69
119
  return candidates[0]
@@ -85,11 +135,9 @@ def _outside_nested_tables(row: html.HtmlElement) -> str:
85
135
  return " ".join(parts).strip()
86
136
 
87
137
 
88
- def _summary(
89
- cells: list[html.HtmlElement],
90
- fields: dict[str, int],
91
- width: int,
92
- ) -> SubjectGradeSummary:
138
+ def _subject_columns(
139
+ cells: list[html.HtmlElement], width: int
140
+ ) -> tuple[str, list[html.HtmlElement]]:
93
141
  if len(cells) < GRADE_BODY_PREFIX_COLUMNS:
94
142
  raise LibrusError(ErrorKind.PARSE)
95
143
  subject = markup.text(cells[1])
@@ -105,6 +153,15 @@ def _summary(
105
153
  columns.extend([cell] * span)
106
154
  if len(columns) != width:
107
155
  raise LibrusError(ErrorKind.PARSE)
156
+ return subject, columns
157
+
158
+
159
+ def _summary(
160
+ cells: list[html.HtmlElement],
161
+ fields: dict[str, int],
162
+ width: int,
163
+ ) -> SubjectGradeSummary:
164
+ subject, columns = _subject_columns(cells, width)
108
165
  selected = [columns[index] for index in fields.values()]
109
166
  if len({id(cell) for cell in selected}) != len(selected):
110
167
  # A single merged value cannot mean two different summary fields.
@@ -118,7 +175,8 @@ def _summary(
118
175
  subject,
119
176
  values.get("midterm", absent),
120
177
  values.get("predicted_annual", absent),
121
- values["annual"],
178
+ # Only a point table may lack the annual column.
179
+ values.get("annual", absent),
122
180
  )
123
181
 
124
182
 
@@ -150,20 +208,70 @@ def _subject_rows(
150
208
  yield cells
151
209
 
152
210
 
211
+ def merge_value(
212
+ first: GradeSummaryValue, other: GradeSummaryValue
213
+ ) -> GradeSummaryValue:
214
+ """One subject's value shown by both grade tables.
215
+
216
+ An assigned value fills an unassigned one and equal values are one value;
217
+ two different assigned values are ambiguous.
218
+ """
219
+ first_set, other_set = (
220
+ value.raw is not None and value.raw not in GRADE_EMPTY_MARKERS
221
+ for value in (first, other)
222
+ )
223
+ if first_set and other_set and first.raw != other.raw:
224
+ raise LibrusError(ErrorKind.PARSE)
225
+ if other_set and not first_set:
226
+ return other
227
+ # An unassigned shown column still says more than an absent one.
228
+ return other if first.availability is Availability.UNAVAILABLE else first
229
+
230
+
153
231
  def parse_final_grades(body: bytes) -> tuple[SubjectGradeSummary, ...]:
154
- table, fields, width = _locate(parse_page(body))
155
- items: list[SubjectGradeSummary] = []
156
- subjects: set[str] = set()
157
- for cells in _subject_rows(table, width):
158
- item = _summary(cells, fields, width)
159
- if item.subject in subjects:
232
+ document = parse_page(body)
233
+ point_tables = [table for table in document.iter("table") if is_point_table(table)]
234
+ if len(point_tables) > 1:
235
+ raise LibrusError(ErrorKind.PARSE)
236
+ # A point-grade school may show the ordinary table or omit it. An ordinary
237
+ # grade table without an annual column is still an unknown layout.
238
+ sources = _candidates(document)
239
+ if len(sources) > 1 or not (sources or point_tables):
240
+ raise LibrusError(ErrorKind.PARSE)
241
+ if point_tables:
242
+ located = {id(table) for table, _, _ in sources}
243
+ if any(
244
+ _current_header(table) and id(table) not in located
245
+ for table in document.iter("table")
246
+ if table not in point_tables
247
+ ):
160
248
  raise LibrusError(ErrorKind.PARSE)
161
- subjects.add(item.subject)
162
- items.append(item)
163
- if len(items) > GRADE_MAX_SUBJECTS:
164
- raise LibrusError(ErrorKind.LIMIT)
249
+ fields, width = point_layout(point_tables[0])
250
+ if fields:
251
+ sources.append((point_tables[0], fields, width))
252
+ items: dict[str, SubjectGradeSummary] = {}
253
+ for table, fields, width in sources:
254
+ family_subjects: set[str] = set()
255
+ for cells in _subject_rows(table, width):
256
+ item = _summary(cells, fields, width)
257
+ if item.subject in family_subjects:
258
+ raise LibrusError(ErrorKind.PARSE)
259
+ family_subjects.add(item.subject)
260
+ previous = items.get(item.subject)
261
+ items[item.subject] = (
262
+ item
263
+ if previous is None
264
+ else SubjectGradeSummary(
265
+ item.subject,
266
+ merge_value(previous.midterm, item.midterm),
267
+ merge_value(previous.predicted_annual, item.predicted_annual),
268
+ merge_value(previous.annual, item.annual),
269
+ )
270
+ )
271
+ if len(items) > GRADE_MAX_SUBJECTS:
272
+ raise LibrusError(ErrorKind.LIMIT)
165
273
  # No evidenced no-subject marker exists. Missing rows are not empty success;
166
274
  # a valid all-unassigned page still lists its subjects with raw '-' values.
167
275
  if not items:
168
276
  raise LibrusError(ErrorKind.PARSE)
169
- return tuple(items)
277
+ return tuple(items.values())
@@ -19,6 +19,8 @@ from librus_python_api.config import (
19
19
  GRADE_MAX_SUBJECTS,
20
20
  GRADE_MERGED_SUBJECTS,
21
21
  GRADE_PERIOD_HEADERS,
22
+ GRADE_POINT_RESULT_HEADER,
23
+ GRADE_POINT_RESULT_PERIODS,
22
24
  GRADE_PREDICTED_ANNUAL_HEADER,
23
25
  GRADE_PREDICTED_PERIOD_HEADERS,
24
26
  GRADE_PUBLICATION_DATE_LABEL,
@@ -27,7 +29,14 @@ from librus_python_api.config import (
27
29
  )
28
30
  from librus_python_api.exceptions import ErrorKind, LibrusError
29
31
  from librus_python_api.formative_grades import is_formative_table, read_formative_table
30
- from librus_python_api.grade_parsers import _locate, _subject_rows, _summary
32
+ from librus_python_api.grade_parsers import (
33
+ _locate,
34
+ _subject_columns,
35
+ _subject_rows,
36
+ _summary,
37
+ is_point_table,
38
+ point_layout,
39
+ )
31
40
  from librus_python_api.models import (
32
41
  Availability,
33
42
  DescriptiveGrade,
@@ -46,7 +55,9 @@ def _label(title: str) -> str:
46
55
  return " ".join(re.split(r"<br\s*/?>", title, flags=re.I)[0].split())
47
56
 
48
57
 
49
- def _layout(table: html.HtmlElement) -> tuple[list[int], dict[int, int]]:
58
+ def _layout(
59
+ table: html.HtmlElement, *, points: bool = False
60
+ ) -> tuple[list[int], dict[int, int]]:
50
61
  current: list[int] = []
51
62
  averages: dict[int, int] = {}
52
63
  for row in markup.rows(table):
@@ -64,6 +75,9 @@ def _layout(table: html.HtmlElement) -> tuple[list[int], dict[int, int]]:
64
75
  raise LibrusError(ErrorKind.PARSE)
65
76
  current.append(position)
66
77
  period = GRADE_AVERAGE_HEADERS.get(_label(cell.get("title", "")))
78
+ result = GRADE_POINT_RESULT_HEADER.fullmatch(markup.text(cell))
79
+ if points and period is None and result is not None:
80
+ period = GRADE_POINT_RESULT_PERIODS[result.group(1)]
67
81
  if period is not None:
68
82
  if period in averages or markup.colspan(cell) != 1:
69
83
  raise LibrusError(ErrorKind.PARSE)
@@ -140,7 +154,9 @@ class _Collection:
140
154
  default_factory=list, repr=False
141
155
  )
142
156
  averages: list[SchoolAverage] = field(default_factory=list, repr=False)
157
+ point_results: list[SchoolAverage] = field(default_factory=list, repr=False)
143
158
  subjects: set[tuple[bool, str]] = field(default_factory=set, repr=False)
159
+ point_subjects: set[str] = field(default_factory=set, repr=False)
144
160
  consumed: set[html.HtmlElement] = field(default_factory=set, repr=False)
145
161
  publication_rows: set[html.HtmlElement] = field(default_factory=set, repr=False)
146
162
  formative: int = 0
@@ -164,7 +180,15 @@ class _Collection:
164
180
  if not value or key in self.subjects:
165
181
  raise LibrusError(ErrorKind.PARSE)
166
182
  self.subjects.add(key)
167
- if len(self.subjects) > GRADE_MAX_SUBJECTS:
183
+ if len(self.subjects) + len(self.point_subjects) > GRADE_MAX_SUBJECTS:
184
+ raise LibrusError(ErrorKind.LIMIT)
185
+
186
+ def point_subject(self, value: str) -> None:
187
+ # A subject can appear in both the ordinary and the point table.
188
+ if not value or value in self.point_subjects:
189
+ raise LibrusError(ErrorKind.PARSE)
190
+ self.point_subjects.add(value)
191
+ if len(self.subjects) + len(self.point_subjects) > GRADE_MAX_SUBJECTS:
168
192
  raise LibrusError(ErrorKind.LIMIT)
169
193
 
170
194
 
@@ -337,19 +361,32 @@ def _period_columns(table: html.HtmlElement) -> dict[tuple[int, GradeKind], int]
337
361
  return result
338
362
 
339
363
 
340
- def _read_numeric_table(table: html.HtmlElement, collection: _Collection) -> None:
341
- # Preserve the established full-row and summary alignment validation.
342
- _, fields, width = _locate(table)
343
- current, means = _layout(table)
364
+ def _read_numeric_table(
365
+ table: html.HtmlElement, collection: _Collection, *, points: bool = False
366
+ ) -> None:
367
+ # Preserve the established full-row and summary alignment validation. A
368
+ # point table aligns on its current-grade header row and may lack summaries.
369
+ if points:
370
+ fields, width = point_layout(table)
371
+ else:
372
+ _, fields, width = _locate(table)
373
+ current, means = _layout(table, points=points)
344
374
  periods = _period_columns(table)
375
+ averages = collection.point_results if points else collection.averages
376
+ # Period grades shown by both tables are one record, kept from the first.
377
+ prior = set(collection.numeric) if points else set()
345
378
  for cells in _subject_rows(table, width):
346
379
  if cells[0].getparent() in collection.publication_rows:
347
380
  continue
348
- if _descriptive_row(cells):
381
+ if points:
382
+ subject = _subject_columns(cells, width)[0]
383
+ collection.point_subject(subject)
384
+ elif _descriptive_row(cells):
349
385
  _read_descriptive_row(cells, collection)
350
386
  continue
351
- subject = _summary(cells, fields, width).subject
352
- collection.subject(subject)
387
+ else:
388
+ subject = _summary(cells, fields, width).subject
389
+ collection.subject(subject)
353
390
  columns = [cell for cell in cells for _ in range(markup.colspan(cell))]
354
391
  for period in (1, 2, 0):
355
392
  index = means.get(period)
@@ -360,7 +397,7 @@ def _read_numeric_table(table: html.HtmlElement, collection: _Collection) -> Non
360
397
  Availability.AVAILABLE, markup.text(columns[index])
361
398
  )
362
399
  )
363
- collection.averages.append(SchoolAverage(subject, period, value))
400
+ averages.append(SchoolAverage(subject, period, value))
364
401
  if columns[current[0]] is columns[current[1]] and _boxes(columns[current[0]]):
365
402
  raise LibrusError(ErrorKind.PARSE)
366
403
  seen: set[html.HtmlElement] = set()
@@ -370,14 +407,23 @@ def _read_numeric_table(table: html.HtmlElement, collection: _Collection) -> Non
370
407
  subject in GRADE_MERGED_SUBJECTS
371
408
  and _behaviour_points(columns[index], collection)
372
409
  ):
410
+ start = len(collection.numeric)
373
411
  _entries(
374
412
  columns[index], subject, cast(Literal[1, 2], period), collection
375
413
  )
414
+ # A point entry without its detail link would pass as a
415
+ # grade symbol; an unseen link layout fails instead.
416
+ if points and any(
417
+ record.point_grade_id is None
418
+ for record in collection.numeric[start:]
419
+ ):
420
+ raise LibrusError(ErrorKind.PARSE)
376
421
  seen.add(columns[index])
377
422
  for (period, kind), index in periods.items():
378
423
  cell = columns[index]
379
424
  if cell in seen and _boxes(cell):
380
425
  raise LibrusError(ErrorKind.PARSE)
426
+ start = len(collection.numeric)
381
427
  _entries(
382
428
  cell,
383
429
  subject,
@@ -386,6 +432,9 @@ def _read_numeric_table(table: html.HtmlElement, collection: _Collection) -> Non
386
432
  kind=kind,
387
433
  require_empty_marker=False,
388
434
  )
435
+ collection.numeric[start:] = [
436
+ record for record in collection.numeric[start:] if record not in prior
437
+ ]
389
438
 
390
439
 
391
440
  def _read_publications(table: html.HtmlElement, collection: _Collection) -> None:
@@ -450,8 +499,13 @@ def parse_grade_records(body: bytes) -> GradeRecords:
450
499
  collection = _Collection()
451
500
  numeric_tables = []
452
501
  tables = list(document.iter("table"))
502
+ point_tables = [table for table in tables if is_point_table(table)]
503
+ if len(point_tables) > 1:
504
+ raise LibrusError(ErrorKind.PARSE)
453
505
  for table in tables:
454
- if not {"decorated", "stretch"} <= set(table.get("class", "").split()):
506
+ if not {"decorated", "stretch"} <= set(
507
+ table.get("class", "").split()
508
+ ) or is_point_table(table):
455
509
  continue
456
510
  if any(
457
511
  next(row.iterancestors("thead"), None) is not None
@@ -472,6 +526,8 @@ def parse_grade_records(body: bytes) -> GradeRecords:
472
526
  _read_publications(table, collection)
473
527
  if table in numeric_tables:
474
528
  _read_numeric_table(table, collection)
529
+ elif table in point_tables:
530
+ _read_numeric_table(table, collection, points=True)
475
531
  else:
476
532
  for row in markup.rows(table):
477
533
  cells = markup.cells(row)
@@ -484,6 +540,7 @@ def parse_grade_records(body: bytes) -> GradeRecords:
484
540
  <= set(cells[0].get("class", "").split())
485
541
  ):
486
542
  raise LibrusError(ErrorKind.PARSE)
543
+ grade_tables = numeric_tables + point_tables
487
544
  for element in document.iter():
488
545
  if not isinstance(element.tag, str) or element in collection.consumed:
489
546
  continue
@@ -491,14 +548,12 @@ def parse_grade_records(body: bytes) -> GradeRecords:
491
548
  "Data:" in (node.get("title") or "") for node in element.iter()
492
549
  )
493
550
  if dated_box:
494
- if any(
495
- parent in numeric_tables for parent in element.iterancestors("table")
496
- ):
551
+ if any(parent in grade_tables for parent in element.iterancestors("table")):
497
552
  # Expanded full-row detail mirrors were intentionally excluded.
498
553
  cell = next(element.iterancestors("td", "th"), None)
499
554
  if (
500
555
  cell is not None
501
- and next(cell.iterancestors("table"), None) not in numeric_tables
556
+ and next(cell.iterancestors("table"), None) not in grade_tables
502
557
  ):
503
558
  continue
504
559
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
@@ -511,7 +566,11 @@ def parse_grade_records(body: bytes) -> GradeRecords:
511
566
  for parent in anchor.iterancestors("table")
512
567
  ):
513
568
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
514
- if not collection.subjects and not collection.descriptive:
569
+ if (
570
+ not collection.subjects
571
+ and not collection.point_subjects
572
+ and not collection.descriptive
573
+ ):
515
574
  raise LibrusError(ErrorKind.PARSE)
516
575
  for descriptive, subject in sorted(collection.subjects):
517
576
  if descriptive and (False, subject) not in collection.subjects:
@@ -529,4 +588,5 @@ def parse_grade_records(body: bytes) -> GradeRecords:
529
588
  tuple(collection.averages),
530
589
  tuple(collection.descriptive_summaries),
531
590
  formative,
591
+ tuple(collection.point_results),
532
592
  )
@@ -1053,6 +1053,19 @@ class NumericGrade:
1053
1053
 
1054
1054
  return detail_id(self.href, FORMATIVE_DETAIL_PATH_PREFIX)
1055
1055
 
1056
+ @property
1057
+ def point_grade_id(self) -> str | None:
1058
+ """The point-grade ID when `href` is a point-grade detail link.
1059
+
1060
+ `raw` is then a score on the scale its category names, such as
1061
+ `(0-100)`, not a grade symbol. Computed for the same identity reason
1062
+ as `formative_id`.
1063
+ """
1064
+ from librus_python_api.config import POINT_GRADE_DETAIL_PATH_PREFIX
1065
+ from librus_python_api.markup import detail_id
1066
+
1067
+ return detail_id(self.href, POINT_GRADE_DETAIL_PATH_PREFIX)
1068
+
1056
1069
 
1057
1070
  @dataclass(frozen=True, slots=True)
1058
1071
  class DescriptiveGrade:
@@ -1123,6 +1136,9 @@ class GradeRecords:
1123
1136
  default=(), repr=False
1124
1137
  )
1125
1138
  formative: tuple[FormativeGrade, ...] = field(default=(), repr=False)
1139
+ # A point table's period results, on its score scale; never merged into
1140
+ # `averages`, whose values are the ordinary grade-scale means.
1141
+ point_results: tuple[SchoolAverage, ...] = field(default=(), repr=False)
1126
1142
 
1127
1143
 
1128
1144
  @dataclass(frozen=True, slots=True)
@@ -921,6 +921,7 @@ dev = [
921
921
  { name = "pip-audit" },
922
922
  { name = "pre-commit" },
923
923
  { name = "pytest" },
924
+ { name = "pytest-timeout" },
924
925
  { name = "pyyaml" },
925
926
  { name = "ruff" },
926
927
  { name = "twine" },
@@ -947,6 +948,7 @@ dev = [
947
948
  { name = "pip-audit", specifier = ">=2.10,<3" },
948
949
  { name = "pre-commit", specifier = ">=4.6,<5" },
949
950
  { name = "pytest", specifier = ">=9,<10" },
951
+ { name = "pytest-timeout", specifier = ">=2.3,<3" },
950
952
  { name = "pyyaml", specifier = ">=6,<7" },
951
953
  { name = "ruff", specifier = ">=0.15,<0.18" },
952
954
  { name = "twine", specifier = ">=7,<8" },
@@ -1797,6 +1799,18 @@ wheels = [
1797
1799
  { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
1798
1800
  ]
1799
1801
 
1802
+ [[package]]
1803
+ name = "pytest-timeout"
1804
+ version = "2.4.0"
1805
+ source = { registry = "https://pypi.org/simple" }
1806
+ dependencies = [
1807
+ { name = "pytest" },
1808
+ ]
1809
+ sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" }
1810
+ wheels = [
1811
+ { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" },
1812
+ ]
1813
+
1800
1814
  [[package]]
1801
1815
  name = "python-discovery"
1802
1816
  version = "1.6.1"