librus-python-api 1.6.4__tar.gz → 1.7.1__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 (57) hide show
  1. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/API.md +9 -0
  2. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/CHANGELOG.md +44 -0
  3. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/PKG-INFO +7 -6
  4. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/README.md +6 -5
  5. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/pyproject.toml +5 -2
  6. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/__init__.py +1 -1
  7. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/config.py +13 -0
  8. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/formative_grades.py +6 -1
  9. librus_python_api-1.7.1/src/librus_python_api/grade_parsers.py +297 -0
  10. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/grade_records.py +92 -26
  11. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/markup.py +11 -1
  12. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/models.py +16 -0
  13. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/uv.lock +14 -0
  14. librus_python_api-1.6.4/src/librus_python_api/grade_parsers.py +0 -169
  15. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/.gitignore +0 -0
  16. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/LICENSE +0 -0
  17. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/SECURITY.md +0 -0
  18. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/_notification_bootstrap.py +0 -0
  19. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/_notification_codec.py +0 -0
  20. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/_storage.py +0 -0
  21. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/_windows_filesystem.py +0 -0
  22. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/announcements.py +0 -0
  23. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/attachment_routes.py +0 -0
  24. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/attachments.py +0 -0
  25. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/attendance.py +0 -0
  26. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/attendance_frequency.py +0 -0
  27. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/budget.py +0 -0
  28. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/checkpoint.py +0 -0
  29. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/class_free_days.py +0 -0
  30. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/completed_lessons.py +0 -0
  31. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/detail_fields.py +0 -0
  32. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/diagnostics.py +0 -0
  33. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/exceptions.py +0 -0
  34. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/files.py +0 -0
  35. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/homework_range.py +0 -0
  36. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/lifecycle.py +0 -0
  37. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/message_content.py +0 -0
  38. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/messages.py +0 -0
  39. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/modern_body.py +0 -0
  40. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/modern_mailbox.py +0 -0
  41. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/modern_messages.py +0 -0
  42. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/notification_models.py +0 -0
  43. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/notification_persistence.py +0 -0
  44. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/notification_workflow.py +0 -0
  45. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/notifications.py +0 -0
  46. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/parsers.py +0 -0
  47. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/parsing.py +0 -0
  48. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/persistence.py +0 -0
  49. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/py.typed +0 -0
  50. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/recipients.py +0 -0
  51. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/scheduler.py +0 -0
  52. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/school_reads.py +0 -0
  53. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/school_year_archive.py +0 -0
  54. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/sending.py +0 -0
  55. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/service.py +0 -0
  56. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/src/librus_python_api/timetable.py +0 -0
  57. {librus_python_api-1.6.4 → librus_python_api-1.7.1}/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,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.1 (2026-10-27) - Observed point-grade page and multi-line comments
4
+
5
+ Fixes for layouts 1.7.0 rejected with `ParseError`, established from an
6
+ affected school's real point-grade page and a live grade read. Public types
7
+ and signatures are unchanged.
8
+
9
+ - Grades: a teacher's multi-line `Komentarz` continues on unlabelled tooltip
10
+ lines and is kept with its line breaks. Every grade read of an account with
11
+ such a comment failed in all releases up to 1.7.0; any other unlabelled
12
+ tooltip line still fails.
13
+ - Point grades: a formative entry among the point entries is accepted as the
14
+ formative mirror it is; the empty footer row wider than the table is
15
+ skipped; titles that break their own label (`Ocena śródroczna <br> z
16
+ pierwszego okresu`) name the period columns, so point midterm grades are
17
+ read. These rules apply to point tables only.
18
+ - Formative assessments: assessment text may contain line breaks. 1.7.0 and
19
+ earlier failed such a table with `UnsupportedCapabilityError`.
20
+
21
+ ## 1.7.0 (2026-10-27) - Point grades
22
+
23
+ Reads the "Oceny punktowe" table that point-grade schools show on the grades
24
+ page. Its presence made `grades()`, `grades_window()` and `final_grades()` fail
25
+ with `ParseError` for those accounts. The layout was established offline from
26
+ a relayed screenshot and a public issue report; live qualification on a
27
+ point-grade account is pending.
28
+
29
+ - Grades: point entries are returned in `numeric` with their raw score, scale
30
+ category and tooltip fields. The new computed `NumericGrade.point_grade_id`
31
+ property identifies them. It is not a field, so notification identities are
32
+ unchanged. A current point entry without its point-grade detail link fails
33
+ instead of passing as a grade symbol.
34
+ - Grades: the new `GradeRecords.point_results` holds the "Wyn." period
35
+ results on the score scale. They are never merged into `averages`, which
36
+ stay the ordinary grade-scale means.
37
+ - Grades: a period grade that both tables show is one record.
38
+ - Final grades: a point table with titled summary columns (midterm, predicted
39
+ or annual) adds its subjects; a missing column is unavailable. For a subject
40
+ in both tables, each value is merged on its own: an assigned value fills an
41
+ unassigned one, equal values are one value and two different assigned values
42
+ fail.
43
+ - More than one point table, duplicate point subjects, misaligned rows and an
44
+ ordinary grade table without an annual column fail instead of returning
45
+ partial output.
46
+
3
47
  ## 1.6.4 (2026-10-26) - Message body rendering security fix
4
48
 
5
49
  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.1
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.1` reads point grades ("Oceny punktowe") as an affected
46
+ school's page shows them, and multi-line grade comments. 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.1
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.1-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.1` reads point grades ("Oceny punktowe") as an affected
20
+ school's page shows them, and multi-line grade comments. 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.1
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.1-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.1"
142
142
 
143
143
  __all__ = [
144
144
  "ArchiveAbsences",
@@ -659,6 +659,12 @@ 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
+ # Tooltip fields whose free text may continue on unlabelled lines. A teacher's
663
+ # multi-line grade comment was observed on 2026-10-27.
664
+ TOOLTIP_MULTILINE_LABELS = frozenset({"Komentarz"})
665
+ # A point-grade ("Oceny punktowe") table marks each subject row's expander image
666
+ # with this ID; ordinary grade rows use `przedmioty_<id>_node`.
667
+ GRADE_POINT_ROW_NODE = re.compile(r"przedmioty_OP_[0-9A-Za-z_]{1,64}_node")
662
668
  GRADE_VIEW_FIELDS = MappingProxyType(
663
669
  {
664
670
  "all": "zmiany_logowanie_wszystkie",
@@ -671,6 +677,11 @@ GRADE_EMPTY_MARKERS = frozenset({"", "-", "Brak ocen"})
671
677
  GRADE_AVERAGE_HEADERS = MappingProxyType(
672
678
  {"Średnia ocen": 1, "Średnia ocen z drugiego okresu": 2, "Średnia roczna": 0}
673
679
  )
680
+ # Point tables label their period results by visible text, not by title. Only
681
+ # "Wyn.I" was observed (owner-relayed screenshot, 2026-10-27); the others
682
+ # follow the same period naming as the ordinary I/II/R summary columns.
683
+ GRADE_POINT_RESULT_HEADER = re.compile(r"Wyn\.\s?(I|II|R)")
684
+ GRADE_POINT_RESULT_PERIODS = MappingProxyType({"I": 1, "II": 2, "R": 0})
674
685
  GRADE_MAX_RECORDS = 2048
675
686
  GRADE_MAX_METADATA_LENGTH = 8192
676
687
  GRADE_MAX_METADATA_FIELDS = 32
@@ -733,6 +744,8 @@ ATTENDANCE_DETAIL_PATH_PREFIX = "/przegladaj_nb/szczegoly/"
733
744
  FORMATIVE_DETAIL_PATH_PREFIX = "/przegladaj_oceny/szczegoly/ksztaltujace/"
734
745
  # Recognition only: behaviour-point detail links are classified, never requested.
735
746
  BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX = "/przegladaj_oceny/zachowanie_szczegoly/"
747
+ # Recognition only: point-grade detail links are classified, never requested.
748
+ POINT_GRADE_DETAIL_PATH_PREFIX = "/przegladaj_oceny_punktowe/szczegoly/"
736
749
  FORMATIVE_HEADERS = (
737
750
  "przedmiot",
738
751
  "ocena kształtująca",
@@ -58,7 +58,8 @@ def _link(cell: html.HtmlElement) -> tuple[str, str]:
58
58
  """The detail ID and full text of the assessment cell.
59
59
 
60
60
  Observed: one link whose optional first child is an empty coloured
61
- `span.grade-box` marker, followed by the text. Anything else is new markup.
61
+ `span.grade-box` marker, followed by the text, which may contain empty
62
+ line breaks (2026-10-27). Anything else is new markup.
62
63
  """
63
64
  _single(cell)
64
65
  anchors = [node for node in cell if isinstance(node.tag, str)]
@@ -66,6 +67,10 @@ def _link(cell: html.HtmlElement) -> tuple[str, str]:
66
67
  _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
67
68
  anchor = anchors[0]
68
69
  children = [node for node in anchor.iterdescendants() if isinstance(node.tag, str)]
70
+ breaks = [node for node in children if node.tag == "br"]
71
+ if any(node.getparent() is not anchor or len(node) for node in breaks):
72
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
73
+ children = [node for node in children if node.tag != "br"]
69
74
  if children:
70
75
  marker = children[0]
71
76
  if (
@@ -0,0 +1,297 @@
1
+ """Original bounded HTML summary parsing from source-informed requirements."""
2
+
3
+ import re
4
+ from collections.abc import Iterator
5
+
6
+ from lxml import html
7
+
8
+ from librus_python_api import markup
9
+ from librus_python_api.config import (
10
+ GRADE_BODY_PREFIX_COLUMNS,
11
+ GRADE_CURRENT_HEADER,
12
+ GRADE_EMPTY_MARKERS,
13
+ GRADE_INLINE_DETAIL_LABEL,
14
+ GRADE_MAX_COLUMNS,
15
+ GRADE_MAX_SUBJECTS,
16
+ GRADE_MAX_VALUE_LENGTH,
17
+ GRADE_MERGED_SUBJECTS,
18
+ GRADE_POINT_ROW_NODE,
19
+ GRADE_SUMMARY_HEADERS,
20
+ )
21
+ from librus_python_api.exceptions import ErrorKind, LibrusError
22
+ from librus_python_api.models import (
23
+ Availability,
24
+ GradeSummaryValue,
25
+ SubjectGradeSummary,
26
+ )
27
+ from librus_python_api.parsers import parse_page
28
+
29
+
30
+ def title_label(title: str, *, points: bool = False) -> str:
31
+ """A header title's label: its first line, or for a point table all of it.
32
+
33
+ Ordinary titles append an explanation after a line break. Point tables
34
+ break the label itself ("Ocena śródroczna <br> z pierwszego okresu").
35
+ """
36
+ if points:
37
+ return " ".join(re.sub(r"<br\s*/?>", " ", title, flags=re.I).split())
38
+ return " ".join(re.split(r"<br\s*/?>", title, flags=re.I)[0].split())
39
+
40
+
41
+ def _summary_field(cell: html.HtmlElement, *, points: bool = False) -> str | None:
42
+ title = cell.get("title", "")
43
+ if len(title) > GRADE_MAX_VALUE_LENGTH:
44
+ raise LibrusError(ErrorKind.LIMIT)
45
+ return GRADE_SUMMARY_HEADERS.get(title_label(title, points=points))
46
+
47
+
48
+ def _header(
49
+ row: html.HtmlElement, *, points: bool = False
50
+ ) -> tuple[dict[str, int], int]:
51
+ fields: dict[str, int] = {}
52
+ position = GRADE_BODY_PREFIX_COLUMNS
53
+ for cell in markup.cells(row):
54
+ span = markup.colspan(cell)
55
+ if position + span > GRADE_MAX_COLUMNS:
56
+ raise LibrusError(ErrorKind.LIMIT)
57
+ field = _summary_field(cell, points=points)
58
+ if field is not None:
59
+ if field in fields:
60
+ raise LibrusError(ErrorKind.PARSE)
61
+ fields[field] = position
62
+ position += span
63
+ return fields, position
64
+
65
+
66
+ def is_point_table(table: html.HtmlElement) -> bool:
67
+ """A grade table whose subject rows carry point-grade expander images."""
68
+ if not {"decorated", "stretch"} <= set(table.get("class", "").split()):
69
+ return False
70
+ return any(
71
+ GRADE_POINT_ROW_NODE.fullmatch(image.get("id", ""))
72
+ for row in markup.rows(table)
73
+ for cell in markup.cells(row)[:1]
74
+ for image in cell.iter("img")
75
+ )
76
+
77
+
78
+ def _current_header(table: html.HtmlElement) -> bool:
79
+ """A grade table: decorated, with a current-grade header row."""
80
+ return {"decorated", "stretch"} <= set(table.get("class", "").split()) and any(
81
+ next(row.iterancestors("thead"), None) is not None
82
+ and any(markup.text(cell) == GRADE_CURRENT_HEADER for cell in markup.cells(row))
83
+ for row in markup.rows(table)
84
+ )
85
+
86
+
87
+ def point_layout(table: html.HtmlElement) -> tuple[dict[str, int], int]:
88
+ """Summary fields and width from the point table's current-grade header row.
89
+
90
+ Point tables are not required to have an annual column, so the row with
91
+ the two current-grade headers defines the body alignment instead.
92
+ """
93
+ headers = [
94
+ row
95
+ for row in markup.rows(table)
96
+ if next(row.iterancestors("thead"), None) is not None
97
+ and any(markup.text(cell) == GRADE_CURRENT_HEADER for cell in markup.cells(row))
98
+ ]
99
+ if len(headers) != 1:
100
+ raise LibrusError(ErrorKind.PARSE)
101
+ return _header(headers[0], points=True)
102
+
103
+
104
+ def _candidates(
105
+ document: html.HtmlElement,
106
+ ) -> list[tuple[html.HtmlElement, dict[str, int], int]]:
107
+ candidates: list[tuple[html.HtmlElement, dict[str, int], int]] = []
108
+ for table in document.iter("table"):
109
+ if not {"decorated", "stretch"} <= set(table.get("class", "").split()):
110
+ continue
111
+ if is_point_table(table):
112
+ continue
113
+ for row in markup.rows(table):
114
+ if next(row.iterancestors("thead"), None) is None:
115
+ continue
116
+ # Group-heading rows can span the entire table. Only the row with
117
+ # an annual summary title defines the body alignment contract.
118
+ if not any(_summary_field(cell) == "annual" for cell in markup.cells(row)):
119
+ continue
120
+ fields, width = _header(row)
121
+ candidates.append((table, fields, width))
122
+ return candidates
123
+
124
+
125
+ def _locate(
126
+ document: html.HtmlElement,
127
+ ) -> tuple[html.HtmlElement, dict[str, int], int]:
128
+ candidates = _candidates(document)
129
+ if len(candidates) != 1:
130
+ raise LibrusError(ErrorKind.PARSE)
131
+ return candidates[0]
132
+
133
+
134
+ def _outside_nested_tables(row: html.HtmlElement) -> str:
135
+ """Reject a subject row masquerading as an expanded-detail wrapper."""
136
+ parts: list[str] = []
137
+ pending = [row]
138
+ while pending:
139
+ element = pending.pop()
140
+ if element.tail:
141
+ parts.append(element.tail)
142
+ if element.tag == "table":
143
+ continue
144
+ if element.text:
145
+ parts.append(element.text)
146
+ pending.extend(element)
147
+ return " ".join(parts).strip()
148
+
149
+
150
+ def _subject_columns(
151
+ cells: list[html.HtmlElement], width: int
152
+ ) -> tuple[str, list[html.HtmlElement]]:
153
+ if len(cells) < GRADE_BODY_PREFIX_COLUMNS:
154
+ raise LibrusError(ErrorKind.PARSE)
155
+ subject = markup.text(cells[1])
156
+ if not subject or markup.colspan(cells[0]) != 1 or markup.colspan(cells[1]) != 1:
157
+ raise LibrusError(ErrorKind.PARSE)
158
+ columns: list[html.HtmlElement] = []
159
+ for cell in cells:
160
+ span = markup.colspan(cell)
161
+ if span > 1 and subject not in GRADE_MERGED_SUBJECTS:
162
+ raise LibrusError(ErrorKind.PARSE)
163
+ if len(columns) + span > GRADE_MAX_COLUMNS:
164
+ raise LibrusError(ErrorKind.LIMIT)
165
+ columns.extend([cell] * span)
166
+ if len(columns) != width:
167
+ raise LibrusError(ErrorKind.PARSE)
168
+ return subject, columns
169
+
170
+
171
+ def _summary(
172
+ cells: list[html.HtmlElement],
173
+ fields: dict[str, int],
174
+ width: int,
175
+ ) -> SubjectGradeSummary:
176
+ subject, columns = _subject_columns(cells, width)
177
+ selected = [columns[index] for index in fields.values()]
178
+ if len({id(cell) for cell in selected}) != len(selected):
179
+ # A single merged value cannot mean two different summary fields.
180
+ raise LibrusError(ErrorKind.PARSE)
181
+ values = {
182
+ name: GradeSummaryValue(Availability.AVAILABLE, markup.text(columns[index]))
183
+ for name, index in fields.items()
184
+ }
185
+ absent = GradeSummaryValue(Availability.UNAVAILABLE, None)
186
+ return SubjectGradeSummary(
187
+ subject,
188
+ values.get("midterm", absent),
189
+ values.get("predicted_annual", absent),
190
+ # Only a point table may lack the annual column.
191
+ values.get("annual", absent),
192
+ )
193
+
194
+
195
+ def _subject_rows(
196
+ table: html.HtmlElement, width: int, *, points: bool = False
197
+ ) -> Iterator[list[html.HtmlElement]]:
198
+ for row in markup.rows(table):
199
+ if next(row.iterancestors("thead"), None) is not None:
200
+ continue
201
+ cells = markup.cells(row)
202
+ if not cells:
203
+ raise LibrusError(ErrorKind.PARSE)
204
+ # The observed point table closes with an empty footer cell wider than
205
+ # the table; only an empty footer row is skipped.
206
+ if (
207
+ points
208
+ and next(row.iterancestors("tfoot"), None) is not None
209
+ and not any(markup.text(cell) for cell in cells)
210
+ ):
211
+ continue
212
+ if list(row.iter("table")):
213
+ if _outside_nested_tables(row):
214
+ raise LibrusError(ErrorKind.PARSE)
215
+ continue
216
+ # An empty full-width layout spacer is not an unassigned subject. Keep
217
+ # this exception narrow: content or a different width remains malformed.
218
+ if (
219
+ len(cells) == 1
220
+ and markup.colspan(cells[0]) == width
221
+ and not markup.text(cells[0])
222
+ ):
223
+ continue
224
+ # Expanded inline detail labels are not subjects. Recognize this narrow
225
+ # source-informed variant, never discard arbitrary malformed subject rows.
226
+ if len(cells) > 1 and markup.text(cells[1]) == GRADE_INLINE_DETAIL_LABEL:
227
+ continue
228
+ yield cells
229
+
230
+
231
+ def merge_value(
232
+ first: GradeSummaryValue, other: GradeSummaryValue
233
+ ) -> GradeSummaryValue:
234
+ """One subject's value shown by both grade tables.
235
+
236
+ An assigned value fills an unassigned one and equal values are one value;
237
+ two different assigned values are ambiguous.
238
+ """
239
+ first_set, other_set = (
240
+ value.raw is not None and value.raw not in GRADE_EMPTY_MARKERS
241
+ for value in (first, other)
242
+ )
243
+ if first_set and other_set and first.raw != other.raw:
244
+ raise LibrusError(ErrorKind.PARSE)
245
+ if other_set and not first_set:
246
+ return other
247
+ # An unassigned shown column still says more than an absent one.
248
+ return other if first.availability is Availability.UNAVAILABLE else first
249
+
250
+
251
+ def parse_final_grades(body: bytes) -> tuple[SubjectGradeSummary, ...]:
252
+ document = parse_page(body)
253
+ point_tables = [table for table in document.iter("table") if is_point_table(table)]
254
+ if len(point_tables) > 1:
255
+ raise LibrusError(ErrorKind.PARSE)
256
+ # A point-grade school may show the ordinary table or omit it. An ordinary
257
+ # grade table without an annual column is still an unknown layout.
258
+ sources = _candidates(document)
259
+ if len(sources) > 1 or not (sources or point_tables):
260
+ raise LibrusError(ErrorKind.PARSE)
261
+ if point_tables:
262
+ located = {id(table) for table, _, _ in sources}
263
+ if any(
264
+ _current_header(table) and id(table) not in located
265
+ for table in document.iter("table")
266
+ if table not in point_tables
267
+ ):
268
+ raise LibrusError(ErrorKind.PARSE)
269
+ fields, width = point_layout(point_tables[0])
270
+ if fields:
271
+ sources.append((point_tables[0], fields, width))
272
+ items: dict[str, SubjectGradeSummary] = {}
273
+ for table, fields, width in sources:
274
+ family_subjects: set[str] = set()
275
+ for cells in _subject_rows(table, width, points=table in point_tables):
276
+ item = _summary(cells, fields, width)
277
+ if item.subject in family_subjects:
278
+ raise LibrusError(ErrorKind.PARSE)
279
+ family_subjects.add(item.subject)
280
+ previous = items.get(item.subject)
281
+ items[item.subject] = (
282
+ item
283
+ if previous is None
284
+ else SubjectGradeSummary(
285
+ item.subject,
286
+ merge_value(previous.midterm, item.midterm),
287
+ merge_value(previous.predicted_annual, item.predicted_annual),
288
+ merge_value(previous.annual, item.annual),
289
+ )
290
+ )
291
+ if len(items) > GRADE_MAX_SUBJECTS:
292
+ raise LibrusError(ErrorKind.LIMIT)
293
+ # No evidenced no-subject marker exists. Missing rows are not empty success;
294
+ # a valid all-unassigned page still lists its subjects with raw '-' values.
295
+ if not items:
296
+ raise LibrusError(ErrorKind.PARSE)
297
+ 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,15 @@ 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
+ title_label,
40
+ )
31
41
  from librus_python_api.models import (
32
42
  Availability,
33
43
  DescriptiveGrade,
@@ -42,11 +52,13 @@ from librus_python_api.models import (
42
52
  from librus_python_api.parsers import parse_page
43
53
 
44
54
 
45
- def _label(title: str) -> str:
46
- return " ".join(re.split(r"<br\s*/?>", title, flags=re.I)[0].split())
55
+ def _label(title: str, *, points: bool = False) -> str:
56
+ return title_label(title, points=points)
47
57
 
48
58
 
49
- def _layout(table: html.HtmlElement) -> tuple[list[int], dict[int, int]]:
59
+ def _layout(
60
+ table: html.HtmlElement, *, points: bool = False
61
+ ) -> tuple[list[int], dict[int, int]]:
50
62
  current: list[int] = []
51
63
  averages: dict[int, int] = {}
52
64
  for row in markup.rows(table):
@@ -63,7 +75,12 @@ def _layout(table: html.HtmlElement) -> tuple[list[int], dict[int, int]]:
63
75
  if markup.colspan(cell) != 1:
64
76
  raise LibrusError(ErrorKind.PARSE)
65
77
  current.append(position)
66
- period = GRADE_AVERAGE_HEADERS.get(_label(cell.get("title", "")))
78
+ period = GRADE_AVERAGE_HEADERS.get(
79
+ _label(cell.get("title", ""), points=points)
80
+ )
81
+ result = GRADE_POINT_RESULT_HEADER.fullmatch(markup.text(cell))
82
+ if points and period is None and result is not None:
83
+ period = GRADE_POINT_RESULT_PERIODS[result.group(1)]
67
84
  if period is not None:
68
85
  if period in averages or markup.colspan(cell) != 1:
69
86
  raise LibrusError(ErrorKind.PARSE)
@@ -140,7 +157,9 @@ class _Collection:
140
157
  default_factory=list, repr=False
141
158
  )
142
159
  averages: list[SchoolAverage] = field(default_factory=list, repr=False)
160
+ point_results: list[SchoolAverage] = field(default_factory=list, repr=False)
143
161
  subjects: set[tuple[bool, str]] = field(default_factory=set, repr=False)
162
+ point_subjects: set[str] = field(default_factory=set, repr=False)
144
163
  consumed: set[html.HtmlElement] = field(default_factory=set, repr=False)
145
164
  publication_rows: set[html.HtmlElement] = field(default_factory=set, repr=False)
146
165
  formative: int = 0
@@ -164,7 +183,15 @@ class _Collection:
164
183
  if not value or key in self.subjects:
165
184
  raise LibrusError(ErrorKind.PARSE)
166
185
  self.subjects.add(key)
167
- if len(self.subjects) > GRADE_MAX_SUBJECTS:
186
+ if len(self.subjects) + len(self.point_subjects) > GRADE_MAX_SUBJECTS:
187
+ raise LibrusError(ErrorKind.LIMIT)
188
+
189
+ def point_subject(self, value: str) -> None:
190
+ # A subject can appear in both the ordinary and the point table.
191
+ if not value or value in self.point_subjects:
192
+ raise LibrusError(ErrorKind.PARSE)
193
+ self.point_subjects.add(value)
194
+ if len(self.subjects) + len(self.point_subjects) > GRADE_MAX_SUBJECTS:
168
195
  raise LibrusError(ErrorKind.LIMIT)
169
196
 
170
197
 
@@ -309,7 +336,9 @@ def _read_descriptive_row(
309
336
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
310
337
 
311
338
 
312
- def _period_columns(table: html.HtmlElement) -> dict[tuple[int, GradeKind], int]:
339
+ def _period_columns(
340
+ table: html.HtmlElement, *, points: bool = False
341
+ ) -> dict[tuple[int, GradeKind], int]:
313
342
  result: dict[tuple[int, GradeKind], int] = {}
314
343
  for row in markup.rows(table):
315
344
  cells = markup.cells(row)
@@ -319,7 +348,7 @@ def _period_columns(table: html.HtmlElement) -> dict[tuple[int, GradeKind], int]
319
348
  continue
320
349
  position = GRADE_BODY_PREFIX_COLUMNS
321
350
  for cell in cells:
322
- label = _label(cell.get("title", ""))
351
+ label = _label(cell.get("title", ""), points=points)
323
352
  period = GRADE_PERIOD_HEADERS.get(label)
324
353
  kind = GradeKind.ANNUAL if period == 0 else GradeKind.PERIOD
325
354
  if label == GRADE_PREDICTED_ANNUAL_HEADER:
@@ -337,19 +366,32 @@ def _period_columns(table: html.HtmlElement) -> dict[tuple[int, GradeKind], int]
337
366
  return result
338
367
 
339
368
 
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)
344
- periods = _period_columns(table)
345
- for cells in _subject_rows(table, width):
369
+ def _read_numeric_table(
370
+ table: html.HtmlElement, collection: _Collection, *, points: bool = False
371
+ ) -> None:
372
+ # Preserve the established full-row and summary alignment validation. A
373
+ # point table aligns on its current-grade header row and may lack summaries.
374
+ if points:
375
+ fields, width = point_layout(table)
376
+ else:
377
+ _, fields, width = _locate(table)
378
+ current, means = _layout(table, points=points)
379
+ periods = _period_columns(table, points=points)
380
+ averages = collection.point_results if points else collection.averages
381
+ # Period grades shown by both tables are one record, kept from the first.
382
+ prior = set(collection.numeric) if points else set()
383
+ for cells in _subject_rows(table, width, points=points):
346
384
  if cells[0].getparent() in collection.publication_rows:
347
385
  continue
348
- if _descriptive_row(cells):
386
+ if points:
387
+ subject = _subject_columns(cells, width)[0]
388
+ collection.point_subject(subject)
389
+ elif _descriptive_row(cells):
349
390
  _read_descriptive_row(cells, collection)
350
391
  continue
351
- subject = _summary(cells, fields, width).subject
352
- collection.subject(subject)
392
+ else:
393
+ subject = _summary(cells, fields, width).subject
394
+ collection.subject(subject)
353
395
  columns = [cell for cell in cells for _ in range(markup.colspan(cell))]
354
396
  for period in (1, 2, 0):
355
397
  index = means.get(period)
@@ -360,7 +402,7 @@ def _read_numeric_table(table: html.HtmlElement, collection: _Collection) -> Non
360
402
  Availability.AVAILABLE, markup.text(columns[index])
361
403
  )
362
404
  )
363
- collection.averages.append(SchoolAverage(subject, period, value))
405
+ averages.append(SchoolAverage(subject, period, value))
364
406
  if columns[current[0]] is columns[current[1]] and _boxes(columns[current[0]]):
365
407
  raise LibrusError(ErrorKind.PARSE)
366
408
  seen: set[html.HtmlElement] = set()
@@ -370,14 +412,24 @@ def _read_numeric_table(table: html.HtmlElement, collection: _Collection) -> Non
370
412
  subject in GRADE_MERGED_SUBJECTS
371
413
  and _behaviour_points(columns[index], collection)
372
414
  ):
415
+ start = len(collection.numeric)
373
416
  _entries(
374
417
  columns[index], subject, cast(Literal[1, 2], period), collection
375
418
  )
419
+ # A point entry without its detail link would pass as a
420
+ # grade symbol; an unseen link layout fails instead. The
421
+ # observed point table also shows formative entries.
422
+ if points and any(
423
+ record.point_grade_id is None and record.formative_id is None
424
+ for record in collection.numeric[start:]
425
+ ):
426
+ raise LibrusError(ErrorKind.PARSE)
376
427
  seen.add(columns[index])
377
428
  for (period, kind), index in periods.items():
378
429
  cell = columns[index]
379
430
  if cell in seen and _boxes(cell):
380
431
  raise LibrusError(ErrorKind.PARSE)
432
+ start = len(collection.numeric)
381
433
  _entries(
382
434
  cell,
383
435
  subject,
@@ -386,6 +438,9 @@ def _read_numeric_table(table: html.HtmlElement, collection: _Collection) -> Non
386
438
  kind=kind,
387
439
  require_empty_marker=False,
388
440
  )
441
+ collection.numeric[start:] = [
442
+ record for record in collection.numeric[start:] if record not in prior
443
+ ]
389
444
 
390
445
 
391
446
  def _read_publications(table: html.HtmlElement, collection: _Collection) -> None:
@@ -450,8 +505,13 @@ def parse_grade_records(body: bytes) -> GradeRecords:
450
505
  collection = _Collection()
451
506
  numeric_tables = []
452
507
  tables = list(document.iter("table"))
508
+ point_tables = [table for table in tables if is_point_table(table)]
509
+ if len(point_tables) > 1:
510
+ raise LibrusError(ErrorKind.PARSE)
453
511
  for table in tables:
454
- if not {"decorated", "stretch"} <= set(table.get("class", "").split()):
512
+ if not {"decorated", "stretch"} <= set(
513
+ table.get("class", "").split()
514
+ ) or is_point_table(table):
455
515
  continue
456
516
  if any(
457
517
  next(row.iterancestors("thead"), None) is not None
@@ -472,6 +532,8 @@ def parse_grade_records(body: bytes) -> GradeRecords:
472
532
  _read_publications(table, collection)
473
533
  if table in numeric_tables:
474
534
  _read_numeric_table(table, collection)
535
+ elif table in point_tables:
536
+ _read_numeric_table(table, collection, points=True)
475
537
  else:
476
538
  for row in markup.rows(table):
477
539
  cells = markup.cells(row)
@@ -484,6 +546,7 @@ def parse_grade_records(body: bytes) -> GradeRecords:
484
546
  <= set(cells[0].get("class", "").split())
485
547
  ):
486
548
  raise LibrusError(ErrorKind.PARSE)
549
+ grade_tables = numeric_tables + point_tables
487
550
  for element in document.iter():
488
551
  if not isinstance(element.tag, str) or element in collection.consumed:
489
552
  continue
@@ -491,27 +554,29 @@ def parse_grade_records(body: bytes) -> GradeRecords:
491
554
  "Data:" in (node.get("title") or "") for node in element.iter()
492
555
  )
493
556
  if dated_box:
494
- if any(
495
- parent in numeric_tables for parent in element.iterancestors("table")
496
- ):
557
+ if any(parent in grade_tables for parent in element.iterancestors("table")):
497
558
  # Expanded full-row detail mirrors were intentionally excluded.
498
559
  cell = next(element.iterancestors("td", "th"), None)
499
560
  if (
500
561
  cell is not None
501
- and next(cell.iterancestors("table"), None) not in numeric_tables
562
+ and next(cell.iterancestors("table"), None) not in grade_tables
502
563
  ):
503
564
  continue
504
565
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
505
566
  for anchor in document.iter("a"):
506
- # A formative link outside both known tables is a new layout.
567
+ # A formative link outside the known tables is a new layout.
507
568
  if markup.detail_id(
508
569
  anchor.get("href"), FORMATIVE_DETAIL_PATH_PREFIX
509
570
  ) and not any(
510
- parent in numeric_tables or parent in formative_tables
571
+ parent in grade_tables or parent in formative_tables
511
572
  for parent in anchor.iterancestors("table")
512
573
  ):
513
574
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
514
- if not collection.subjects and not collection.descriptive:
575
+ if (
576
+ not collection.subjects
577
+ and not collection.point_subjects
578
+ and not collection.descriptive
579
+ ):
515
580
  raise LibrusError(ErrorKind.PARSE)
516
581
  for descriptive, subject in sorted(collection.subjects):
517
582
  if descriptive and (False, subject) not in collection.subjects:
@@ -529,4 +594,5 @@ def parse_grade_records(body: bytes) -> GradeRecords:
529
594
  tuple(collection.averages),
530
595
  tuple(collection.descriptive_summaries),
531
596
  formative,
597
+ tuple(collection.point_results),
532
598
  )
@@ -13,6 +13,7 @@ from librus_python_api.config import (
13
13
  GRADE_MAX_COLUMNS,
14
14
  GRADE_MAX_METADATA_FIELDS,
15
15
  GRADE_MAX_METADATA_LENGTH,
16
+ TOOLTIP_MULTILINE_LABELS,
16
17
  UPSTREAM_ORIGINS,
17
18
  )
18
19
  from librus_python_api.exceptions import ErrorKind, LibrusError
@@ -81,11 +82,16 @@ def colspan(cell: html.HtmlElement) -> int:
81
82
 
82
83
 
83
84
  def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
84
- """Parse a 'Label: value<br>' title attribute into unique labelled fields."""
85
+ """Parse a 'Label: value<br>' title attribute into unique labelled fields.
86
+
87
+ A free-text field may span lines; an unlabelled line after it continues
88
+ its value. Any other unlabelled line is an unknown layout.
89
+ """
85
90
  title = element.get("title", "")
86
91
  if len(title) > GRADE_MAX_METADATA_LENGTH:
87
92
  raise LibrusError(ErrorKind.LIMIT)
88
93
  result: dict[str, str] = {}
94
+ last: str | None = None
89
95
  chunks = re.split(r"<br\s*/?>|\n", title, flags=re.I)
90
96
  if len(chunks) > GRADE_MAX_METADATA_FIELDS:
91
97
  raise LibrusError(ErrorKind.LIMIT)
@@ -100,9 +106,13 @@ def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
100
106
  )
101
107
  key, separator, value = rendered.partition(":")
102
108
  key, value = key.strip(), value.strip()
109
+ if not separator and last in TOOLTIP_MULTILINE_LABELS:
110
+ result[last] = f"{result[last]}\n{rendered}".strip()
111
+ continue
103
112
  if not separator or not key or key in result:
104
113
  raise LibrusError(ErrorKind.PARSE)
105
114
  result[key] = value
115
+ last = key
106
116
  return result
107
117
 
108
118
 
@@ -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"
@@ -1,169 +0,0 @@
1
- """Original bounded HTML summary parsing from source-informed requirements."""
2
-
3
- import re
4
- from collections.abc import Iterator
5
-
6
- from lxml import html
7
-
8
- from librus_python_api import markup
9
- from librus_python_api.config import (
10
- GRADE_BODY_PREFIX_COLUMNS,
11
- GRADE_INLINE_DETAIL_LABEL,
12
- GRADE_MAX_COLUMNS,
13
- GRADE_MAX_SUBJECTS,
14
- GRADE_MAX_VALUE_LENGTH,
15
- GRADE_MERGED_SUBJECTS,
16
- GRADE_SUMMARY_HEADERS,
17
- )
18
- from librus_python_api.exceptions import ErrorKind, LibrusError
19
- from librus_python_api.models import (
20
- Availability,
21
- GradeSummaryValue,
22
- SubjectGradeSummary,
23
- )
24
- from librus_python_api.parsers import parse_page
25
-
26
-
27
- def _summary_field(cell: html.HtmlElement) -> str | None:
28
- title = cell.get("title", "")
29
- if len(title) > GRADE_MAX_VALUE_LENGTH:
30
- raise LibrusError(ErrorKind.LIMIT)
31
- label = " ".join(re.split(r"<br\s*/?>", title, flags=re.I)[0].split())
32
- return GRADE_SUMMARY_HEADERS.get(label)
33
-
34
-
35
- def _header(row: html.HtmlElement) -> tuple[dict[str, int], int]:
36
- fields: dict[str, int] = {}
37
- position = GRADE_BODY_PREFIX_COLUMNS
38
- for cell in markup.cells(row):
39
- span = markup.colspan(cell)
40
- if position + span > GRADE_MAX_COLUMNS:
41
- raise LibrusError(ErrorKind.LIMIT)
42
- field = _summary_field(cell)
43
- if field is not None:
44
- if field in fields:
45
- raise LibrusError(ErrorKind.PARSE)
46
- fields[field] = position
47
- position += span
48
- return fields, position
49
-
50
-
51
- def _locate(
52
- document: html.HtmlElement,
53
- ) -> tuple[html.HtmlElement, dict[str, int], int]:
54
- candidates: list[tuple[html.HtmlElement, dict[str, int], int]] = []
55
- for table in document.iter("table"):
56
- if not {"decorated", "stretch"} <= set(table.get("class", "").split()):
57
- continue
58
- for row in markup.rows(table):
59
- if next(row.iterancestors("thead"), None) is None:
60
- continue
61
- # Group-heading rows can span the entire table. Only the row with
62
- # an annual summary title defines the body alignment contract.
63
- if not any(_summary_field(cell) == "annual" for cell in markup.cells(row)):
64
- continue
65
- fields, width = _header(row)
66
- candidates.append((table, fields, width))
67
- if len(candidates) != 1:
68
- raise LibrusError(ErrorKind.PARSE)
69
- return candidates[0]
70
-
71
-
72
- def _outside_nested_tables(row: html.HtmlElement) -> str:
73
- """Reject a subject row masquerading as an expanded-detail wrapper."""
74
- parts: list[str] = []
75
- pending = [row]
76
- while pending:
77
- element = pending.pop()
78
- if element.tail:
79
- parts.append(element.tail)
80
- if element.tag == "table":
81
- continue
82
- if element.text:
83
- parts.append(element.text)
84
- pending.extend(element)
85
- return " ".join(parts).strip()
86
-
87
-
88
- def _summary(
89
- cells: list[html.HtmlElement],
90
- fields: dict[str, int],
91
- width: int,
92
- ) -> SubjectGradeSummary:
93
- if len(cells) < GRADE_BODY_PREFIX_COLUMNS:
94
- raise LibrusError(ErrorKind.PARSE)
95
- subject = markup.text(cells[1])
96
- if not subject or markup.colspan(cells[0]) != 1 or markup.colspan(cells[1]) != 1:
97
- raise LibrusError(ErrorKind.PARSE)
98
- columns: list[html.HtmlElement] = []
99
- for cell in cells:
100
- span = markup.colspan(cell)
101
- if span > 1 and subject not in GRADE_MERGED_SUBJECTS:
102
- raise LibrusError(ErrorKind.PARSE)
103
- if len(columns) + span > GRADE_MAX_COLUMNS:
104
- raise LibrusError(ErrorKind.LIMIT)
105
- columns.extend([cell] * span)
106
- if len(columns) != width:
107
- raise LibrusError(ErrorKind.PARSE)
108
- selected = [columns[index] for index in fields.values()]
109
- if len({id(cell) for cell in selected}) != len(selected):
110
- # A single merged value cannot mean two different summary fields.
111
- raise LibrusError(ErrorKind.PARSE)
112
- values = {
113
- name: GradeSummaryValue(Availability.AVAILABLE, markup.text(columns[index]))
114
- for name, index in fields.items()
115
- }
116
- absent = GradeSummaryValue(Availability.UNAVAILABLE, None)
117
- return SubjectGradeSummary(
118
- subject,
119
- values.get("midterm", absent),
120
- values.get("predicted_annual", absent),
121
- values["annual"],
122
- )
123
-
124
-
125
- def _subject_rows(
126
- table: html.HtmlElement, width: int
127
- ) -> Iterator[list[html.HtmlElement]]:
128
- for row in markup.rows(table):
129
- if next(row.iterancestors("thead"), None) is not None:
130
- continue
131
- cells = markup.cells(row)
132
- if not cells:
133
- raise LibrusError(ErrorKind.PARSE)
134
- if list(row.iter("table")):
135
- if _outside_nested_tables(row):
136
- raise LibrusError(ErrorKind.PARSE)
137
- continue
138
- # An empty full-width layout spacer is not an unassigned subject. Keep
139
- # this exception narrow: content or a different width remains malformed.
140
- if (
141
- len(cells) == 1
142
- and markup.colspan(cells[0]) == width
143
- and not markup.text(cells[0])
144
- ):
145
- continue
146
- # Expanded inline detail labels are not subjects. Recognize this narrow
147
- # source-informed variant, never discard arbitrary malformed subject rows.
148
- if len(cells) > 1 and markup.text(cells[1]) == GRADE_INLINE_DETAIL_LABEL:
149
- continue
150
- yield cells
151
-
152
-
153
- 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:
160
- 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)
165
- # No evidenced no-subject marker exists. Missing rows are not empty success;
166
- # a valid all-unassigned page still lists its subjects with raw '-' values.
167
- if not items:
168
- raise LibrusError(ErrorKind.PARSE)
169
- return tuple(items)