librus-python-api 1.6.1__tar.gz → 1.6.3__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.1 → librus_python_api-1.6.3}/CHANGELOG.md +47 -0
  2. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/PKG-INFO +8 -8
  3. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/README.md +6 -6
  4. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/pyproject.toml +2 -2
  5. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/__init__.py +1 -1
  6. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/completed_lessons.py +85 -13
  7. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/config.py +6 -1
  8. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/detail_fields.py +0 -1
  9. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/formative_grades.py +0 -1
  10. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/grade_records.py +53 -3
  11. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/notification_persistence.py +0 -1
  12. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/persistence.py +1 -1
  13. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/transport.py +91 -102
  14. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/uv.lock +224 -151
  15. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/.gitignore +0 -0
  16. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/API.md +0 -0
  17. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/LICENSE +0 -0
  18. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/SECURITY.md +0 -0
  19. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/_notification_bootstrap.py +0 -0
  20. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/_notification_codec.py +0 -0
  21. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/_storage.py +0 -0
  22. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/_windows_filesystem.py +0 -0
  23. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/announcements.py +0 -0
  24. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/attachment_routes.py +0 -0
  25. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/attachments.py +0 -0
  26. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/attendance.py +0 -0
  27. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/attendance_frequency.py +0 -0
  28. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/budget.py +0 -0
  29. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/checkpoint.py +0 -0
  30. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/class_free_days.py +0 -0
  31. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/diagnostics.py +0 -0
  32. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/exceptions.py +0 -0
  33. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/files.py +0 -0
  34. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/grade_parsers.py +0 -0
  35. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/homework_range.py +0 -0
  36. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/lifecycle.py +0 -0
  37. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/markup.py +0 -0
  38. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/message_content.py +0 -0
  39. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/messages.py +0 -0
  40. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/models.py +0 -0
  41. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/modern_body.py +0 -0
  42. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/modern_mailbox.py +0 -0
  43. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/modern_messages.py +0 -0
  44. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/notification_models.py +0 -0
  45. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/notification_workflow.py +0 -0
  46. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/notifications.py +0 -0
  47. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/parsers.py +0 -0
  48. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/parsing.py +0 -0
  49. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/py.typed +0 -0
  50. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/recipients.py +0 -0
  51. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/scheduler.py +0 -0
  52. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/school_reads.py +0 -0
  53. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/school_year_archive.py +0 -0
  54. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/sending.py +0 -0
  55. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/service.py +0 -0
  56. {librus_python_api-1.6.1 → librus_python_api-1.6.3}/src/librus_python_api/timetable.py +0 -0
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.3 (2026-10-22) - Dependency security update
4
+
5
+ Raises dependency floors and tooling pins past published advisories. Public
6
+ types, signatures and parser behaviour are unchanged.
7
+
8
+ - Runtime: require `aiohttp>=3.14.4`. Earlier 3.14 releases are affected by
9
+ GHSA-494j-54xm-rwp3 (a Secure cookie in the shared cookie jar could be sent
10
+ over plain HTTP) and GHSA-gx76-c99x-2c96 (a subdomain could strip the
11
+ host-only flag from a parent-domain cookie), both relevant to the client
12
+ cookie jar, plus server-side advisories that this client does not reach. The
13
+ lock already used 3.14.5; the floor stops a resolver from choosing an affected
14
+ release.
15
+ - Development lock: certifi 2026.10.19 drops the compromised emSign ECC Root CA
16
+ G3 (GHSA-6q48-6c7w-9gw2), and virtualenv 21.14.12 fixes activation-script and
17
+ path quoting advisories. Neither is a runtime dependency.
18
+ - CI and release workflows pin uv 0.13.5 with per-platform checksums. uv 0.12.7
19
+ trusted the same compromised root (GHSA-hq85-5cpj-mr62) and could write
20
+ outside the install prefix on Windows (GHSA-2cv4-cqwr-gwf7). The lockfile
21
+ format revision moves from 3 to 5 with no resolution change.
22
+
23
+ ## 1.6.2 (2026-10-19) - School layout variants and behaviour points
24
+
25
+ Fixes for completed-lesson and grade pages that 1.6.1 rejected with
26
+ `ParseError`. The layouts were observed offline in owner-provided pages and one
27
+ issue report; the guarded live gate for them is pending. Public types and
28
+ signatures are unchanged.
29
+
30
+ - Completed lessons: accept the item range after the page count
31
+ (`Strona 1 z 3 Pozycje 1..20 z 47`) and require it to match the parsed rows;
32
+ ignore the `decorated filters` date-filter table; locate the optional
33
+ `e-Tablica` column by its header label (nonempty cells raise
34
+ `UnsupportedCapabilityError`); skip a blank full-width closing row.
35
+ - Completed lessons: strip whitespace and line breaks around the subject/teacher
36
+ comma, so `<b>Subject</b>, <br>Teacher` no longer yields the whole cell as the
37
+ subject with no teacher.
38
+ - Grades: recognize behaviour points in the `Zachowanie` row (a starting-points
39
+ or running-total tooltip, and point entries linking to behaviour details).
40
+ They are validated and not returned as grades, so the whole grade read no
41
+ longer fails on them (#76). Other content beside the points tooltip still
42
+ fails.
43
+ - Internal: share aiohttp session setup and message send dispatch in the
44
+ transport, remove an unused recipient constant, orphaned 0.4.x qualification
45
+ scripts and dead test-support code, and correct stale status sentences in the
46
+ contracts. Behaviour is unchanged.
47
+ - Development dependencies and pinned GitHub Actions are updated; runtime
48
+ dependency ranges are unchanged.
49
+
3
50
  ## 1.6.1 (2026-10-12) - Sunday grade dates and calendar closures
4
51
 
5
52
  - Accept both observed Sunday tooltip labels, `nd.` and `ndz.`, while retaining
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: librus-python-api
3
- Version: 1.6.1
3
+ Version: 1.6.3
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
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Programming Language :: Python :: 3.14
17
17
  Classifier: Typing :: Typed
18
18
  Requires-Python: >=3.13
19
- Requires-Dist: aiohttp<4,>=3.14.2
19
+ Requires-Dist: aiohttp<4,>=3.14.4
20
20
  Requires-Dist: lxml<7,>=6.1
21
21
  Requires-Dist: pydantic<3,>=2.12
22
22
  Requires-Dist: pywin32<400,>=312; sys_platform == 'win32'
@@ -42,8 +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.6.1` fixes Sunday grade-date labels and calendar closure events.
46
- Class free days, typed capability outcomes and the live-check freshness watchdog
45
+ **Status:** `1.6.3` requires an aiohttp release without known advisories and
46
+ keeps the 1.6.2 completed-lesson and grade layouts, including behaviour points,
47
+ which are validated and not reported as grades. Class free days, typed capability outcomes and the live-check freshness watchdog
47
48
  remain available. See [verification](VERIFICATION.md).
48
49
  The stable 1.x API does not guarantee every school layout. School features depend
49
50
  on what each account can access. See the [compatibility policy](API.md#compatibility-policy).
@@ -54,7 +55,7 @@ See [limitations](#supported-features-and-limitations) below.
54
55
  Install the exact release from PyPI after its gated publication completes:
55
56
 
56
57
  ```sh
57
- python -m pip install librus-python-api==1.6.1
58
+ python -m pip install librus-python-api==1.6.3
58
59
  ```
59
60
 
60
61
  Pin the exact version qualified by your application. Before publication, use a
@@ -70,7 +71,7 @@ python -m pip install .
70
71
  To install a locally built wheel instead:
71
72
 
72
73
  ```sh
73
- python -m pip install ./dist/librus_python_api-1.6.1-py3-none-any.whl
74
+ python -m pip install ./dist/librus_python_api-1.6.3-py3-none-any.whl
74
75
  ```
75
76
 
76
77
  No CLI or background process is installed: import the library in your own program.
@@ -249,7 +250,7 @@ Completed lessons may be disabled by the school. Behaviour notes are not
249
250
  implemented. Some recipient, archive and receipt layouts remain
250
251
  unqualified; backend acceptance is not proof of delivery. Tests cover supported
251
252
  contracts, not every school or role. Detailed coverage is in the
252
- [verification log][verification] and [roadmap][roadmap].
253
+ [verification log][verification]; open work is tracked in [issues][issues].
253
254
 
254
255
  ## Development and support
255
256
 
@@ -264,7 +265,6 @@ MIT licensed. See [LICENSE][license].
264
265
  [api]: https://github.com/krzysztofbury/librus-python-api/blob/main/API.md
265
266
  [upgrade]: https://github.com/krzysztofbury/librus-python-api/blob/main/contracts/account-context.md
266
267
  [verification]: https://github.com/krzysztofbury/librus-python-api/blob/main/VERIFICATION.md
267
- [roadmap]: https://github.com/krzysztofbury/librus-python-api/blob/main/TODO.md
268
268
  [contributing]: https://github.com/krzysztofbury/librus-python-api/blob/main/CONTRIBUTING.md
269
269
  [issues]: https://github.com/krzysztofbury/librus-python-api/issues
270
270
  [security]: https://github.com/krzysztofbury/librus-python-api/blob/main/SECURITY.md
@@ -16,8 +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.6.1` fixes Sunday grade-date labels and calendar closure events.
20
- Class free days, typed capability outcomes and the live-check freshness watchdog
19
+ **Status:** `1.6.3` requires an aiohttp release without known advisories and
20
+ keeps the 1.6.2 completed-lesson and grade layouts, including behaviour points,
21
+ which are validated and not reported as grades. Class free days, typed capability outcomes and the live-check freshness watchdog
21
22
  remain available. See [verification](VERIFICATION.md).
22
23
  The stable 1.x API does not guarantee every school layout. School features depend
23
24
  on what each account can access. See the [compatibility policy](API.md#compatibility-policy).
@@ -28,7 +29,7 @@ See [limitations](#supported-features-and-limitations) below.
28
29
  Install the exact release from PyPI after its gated publication completes:
29
30
 
30
31
  ```sh
31
- python -m pip install librus-python-api==1.6.1
32
+ python -m pip install librus-python-api==1.6.3
32
33
  ```
33
34
 
34
35
  Pin the exact version qualified by your application. Before publication, use a
@@ -44,7 +45,7 @@ python -m pip install .
44
45
  To install a locally built wheel instead:
45
46
 
46
47
  ```sh
47
- python -m pip install ./dist/librus_python_api-1.6.1-py3-none-any.whl
48
+ python -m pip install ./dist/librus_python_api-1.6.3-py3-none-any.whl
48
49
  ```
49
50
 
50
51
  No CLI or background process is installed: import the library in your own program.
@@ -223,7 +224,7 @@ Completed lessons may be disabled by the school. Behaviour notes are not
223
224
  implemented. Some recipient, archive and receipt layouts remain
224
225
  unqualified; backend acceptance is not proof of delivery. Tests cover supported
225
226
  contracts, not every school or role. Detailed coverage is in the
226
- [verification log][verification] and [roadmap][roadmap].
227
+ [verification log][verification]; open work is tracked in [issues][issues].
227
228
 
228
229
  ## Development and support
229
230
 
@@ -238,7 +239,6 @@ MIT licensed. See [LICENSE][license].
238
239
  [api]: https://github.com/krzysztofbury/librus-python-api/blob/main/API.md
239
240
  [upgrade]: https://github.com/krzysztofbury/librus-python-api/blob/main/contracts/account-context.md
240
241
  [verification]: https://github.com/krzysztofbury/librus-python-api/blob/main/VERIFICATION.md
241
- [roadmap]: https://github.com/krzysztofbury/librus-python-api/blob/main/TODO.md
242
242
  [contributing]: https://github.com/krzysztofbury/librus-python-api/blob/main/CONTRIBUTING.md
243
243
  [issues]: https://github.com/krzysztofbury/librus-python-api/issues
244
244
  [security]: https://github.com/krzysztofbury/librus-python-api/blob/main/SECURITY.md
@@ -21,7 +21,7 @@ classifiers = [
21
21
  "Programming Language :: Python :: 3.14",
22
22
  "Typing :: Typed",
23
23
  ]
24
- dependencies = ["aiohttp>=3.14.2,<4", "lxml>=6.1,<7", "pydantic>=2.12,<3", "yarl>=1.20,<2", "pywin32>=312,<400; sys_platform == 'win32'", "tzdata>=2026.5; sys_platform == 'win32'"]
24
+ dependencies = ["aiohttp>=3.14.4,<4", "lxml>=6.1,<7", "pydantic>=2.12,<3", "yarl>=1.20,<2", "pywin32>=312,<400; sys_platform == 'win32'", "tzdata>=2026.5; sys_platform == 'win32'"]
25
25
 
26
26
  [project.urls]
27
27
  Repository = "https://github.com/krzysztofbury/librus-python-api"
@@ -35,7 +35,7 @@ dev = [
35
35
  "openapi-spec-validator>=0.7,<0.10",
36
36
  "pytest>=9,<10",
37
37
  "pyyaml>=6,<7",
38
- "ruff>=0.15,<0.17",
38
+ "ruff>=0.15,<0.18",
39
39
  "types-pyyaml>=6,<7",
40
40
  "types-lxml>=2026.2.16,<2027",
41
41
  "types-pywin32>=312,<400",
@@ -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.1"
141
+ __version__ = "1.6.3"
142
142
 
143
143
  __all__ = [
144
144
  "ArchiveAbsences",
@@ -9,10 +9,12 @@ from lxml import html
9
9
 
10
10
  from librus_python_api import markup
11
11
  from librus_python_api.config import (
12
+ COMPLETED_LESSONS_COLUMNS,
12
13
  COMPLETED_LESSONS_MAX_BATCH_ITEMS,
13
14
  COMPLETED_LESSONS_MAX_BATCH_PAGES,
14
15
  COMPLETED_LESSONS_MAX_PAGE_COUNT,
15
16
  COMPLETED_LESSONS_MAX_PAGE_ITEMS,
17
+ COMPLETED_LESSONS_OPTIONAL_HEADERS,
16
18
  SCHOOL_MAX_CONTENT_LENGTH,
17
19
  SCHOOL_MAX_FIELD_LENGTH,
18
20
  SCHOOL_MAX_TOTAL_TEXT_LENGTH,
@@ -65,7 +67,8 @@ def validate_selection(
65
67
  completed_lessons_form(cursor.start, cursor.end, cursor.page)
66
68
 
67
69
 
68
- def _pagination(document: html.HtmlElement, page: int) -> int:
70
+ def _pagination(document: html.HtmlElement, page: int) -> tuple[int, int | None]:
71
+ """The page count and, when shown, the number of rows the page reports."""
69
72
  containers = [
70
73
  node
71
74
  for node in document.iter("div")
@@ -74,14 +77,19 @@ def _pagination(document: html.HtmlElement, page: int) -> int:
74
77
  if not containers:
75
78
  if page != 0:
76
79
  raise LibrusError(ErrorKind.PARSE)
77
- return 1
80
+ return 1, None
78
81
  if len(containers) != 1:
79
82
  raise LibrusError(ErrorKind.PARSE)
80
83
  spans = [node for node in containers[0] if node.tag == "span"]
81
84
  if len(spans) != 1:
82
85
  raise LibrusError(ErrorKind.PARSE)
83
86
  value = markup.text(spans[0], SCHOOL_MAX_FIELD_LENGTH)
84
- match = re.fullmatch(r"(?:Strona\s+)?([0-9]{1,4})\s+z\s+([0-9]{1,4})", value, re.I)
87
+ match = re.fullmatch(
88
+ r"(?:Strona\s+)?([0-9]{1,4})\s+z\s+([0-9]{1,4})"
89
+ r"(?:\s+Pozycje\s+([0-9]{1,6})\.\.([0-9]{1,6})\s+z\s+([0-9]{1,6}))?",
90
+ value,
91
+ re.I,
92
+ )
85
93
  if match is None:
86
94
  raise LibrusError(ErrorKind.PARSE)
87
95
  current, count = int(match[1]), int(match[2])
@@ -89,7 +97,53 @@ def _pagination(document: html.HtmlElement, page: int) -> int:
89
97
  raise LibrusError(ErrorKind.LIMIT)
90
98
  if not 1 <= current <= count or current != page + 1:
91
99
  raise LibrusError(ErrorKind.PARSE)
92
- return count
100
+ if match[3] is None:
101
+ return count, None
102
+ # Some schools append the item range: "Pozycje first..last z total".
103
+ first, last, total = int(match[3]), int(match[4]), int(match[5])
104
+ if not 1 <= first <= last <= total:
105
+ raise LibrusError(ErrorKind.PARSE)
106
+ return count, last - first + 1
107
+
108
+
109
+ def _optional_columns(table: html.HtmlElement) -> frozenset[int]:
110
+ """Row positions of optional columns, located by their header labels."""
111
+ positions: set[int] = set()
112
+ for row in markup.rows(table):
113
+ if not markup.in_header(row):
114
+ continue
115
+ position = 0
116
+ labels = [
117
+ (cell, markup.text(cell, SCHOOL_MAX_FIELD_LENGTH))
118
+ for cell in markup.cells(row)
119
+ ]
120
+ if not any(label in COMPLETED_LESSONS_OPTIONAL_HEADERS for _, label in labels):
121
+ continue
122
+ if positions:
123
+ raise LibrusError(ErrorKind.PARSE)
124
+ for cell, label in labels:
125
+ span = markup.colspan(cell)
126
+ if label in COMPLETED_LESSONS_OPTIONAL_HEADERS:
127
+ if span != 1:
128
+ raise LibrusError(ErrorKind.PARSE)
129
+ positions.add(position)
130
+ position += span
131
+ # A labelled header must describe exactly the row layout it extends.
132
+ if position != COMPLETED_LESSONS_COLUMNS + len(positions):
133
+ raise LibrusError(ErrorKind.PARSE)
134
+ return frozenset(positions)
135
+
136
+
137
+ def _closing_row(row: html.HtmlElement, width: int) -> bool:
138
+ """A blank full-width row that closes some schools' tables, not a lesson."""
139
+ cells = markup.cells(row)
140
+ return (
141
+ len(cells) == 1
142
+ and cells[0].tag == "td"
143
+ and markup.colspan(cells[0]) == width
144
+ and not any(isinstance(node.tag, str) for node in cells[0])
145
+ and not markup.text(cells[0], SCHOOL_MAX_FIELD_LENGTH)
146
+ )
93
147
 
94
148
 
95
149
  def _day(value: str) -> date:
@@ -109,15 +163,24 @@ def _day(value: str) -> date:
109
163
  return result
110
164
 
111
165
 
112
- def _lesson(row: html.HtmlElement, start: date, end: date) -> CompletedLesson:
166
+ def _lesson(
167
+ row: html.HtmlElement, start: date, end: date, optional: frozenset[int]
168
+ ) -> CompletedLesson:
113
169
  cells = markup.cells(row)
114
- if len(cells) != 7 or any(
170
+ if len(cells) != COMPLETED_LESSONS_COLUMNS + len(optional) or any(
115
171
  cell.tag != "td"
116
172
  or cell.get("rowspan", "1") != "1"
117
173
  or cell.get("colspan", "1") != "1"
118
174
  for cell in cells
119
175
  ):
120
176
  raise LibrusError(ErrorKind.PARSE)
177
+ for index in optional:
178
+ # Content in an optional column is not modelled; never drop it silently.
179
+ if any(isinstance(node.tag, str) for node in cells[index]) or markup.text(
180
+ cells[index], SCHOOL_MAX_FIELD_LENGTH
181
+ ):
182
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
183
+ cells = [cell for index, cell in enumerate(cells) if index not in optional]
121
184
  date_cells = [
122
185
  c for c in cells if {"center", "small"} <= set(c.get("class", "").split())
123
186
  ]
@@ -140,7 +203,8 @@ def _lesson(row: html.HtmlElement, start: date, end: date) -> CompletedLesson:
140
203
  raw_number, combined, topic, z_value, symbol = values
141
204
  if raw_number not in {"", "-"} and re.fullmatch(r"[0-9]{1,2}", raw_number) is None:
142
205
  raise LibrusError(ErrorKind.PARSE)
143
- names = combined.split(", ")
206
+ # Some schools render "<b>Subject</b>, <br>Teacher" or pad the comma.
207
+ names = [name.strip() for name in combined.split(",")]
144
208
  subject, teacher = (names[0], names[1]) if len(names) == 2 else (combined, None)
145
209
  if not subject or teacher == "":
146
210
  raise LibrusError(ErrorKind.PARSE)
@@ -174,9 +238,13 @@ def parse_completed_lessons(
174
238
  ) -> tuple[tuple[CompletedLesson, ...], int, str]:
175
239
  completed_lessons_form(start, end, page)
176
240
  document = parse_page(body)
177
- count = _pagination(document, page)
241
+ count, reported = _pagination(document, page)
242
+ # A date-filter form table shares the "decorated" class with the data table.
178
243
  tables = [
179
- t for t in document.iter("table") if "decorated" in t.get("class", "").split()
244
+ t
245
+ for t in document.iter("table")
246
+ if "decorated" in (classes := t.get("class", "").split())
247
+ and "filters" not in classes
180
248
  ]
181
249
  empty = [
182
250
  n
@@ -190,10 +258,12 @@ def parse_completed_lessons(
190
258
  if tables:
191
259
  if list(tables[0].iterdescendants("table")):
192
260
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
193
- for row in markup.rows(tables[0]):
194
- if next(row.iterancestors("thead"), None) is not None:
195
- continue
196
- item = _lesson(row, start, end)
261
+ optional = _optional_columns(tables[0])
262
+ lines = [row for row in markup.rows(tables[0]) if not markup.in_header(row)]
263
+ if lines and _closing_row(lines[-1], COMPLETED_LESSONS_COLUMNS + len(optional)):
264
+ lines.pop()
265
+ for row in lines:
266
+ item = _lesson(row, start, end, optional)
197
267
  total += sum(len(v) for v in astuple(item) if isinstance(v, str))
198
268
  items.append(item)
199
269
  if (
@@ -201,6 +271,8 @@ def parse_completed_lessons(
201
271
  or total > SCHOOL_MAX_TOTAL_TEXT_LENGTH
202
272
  ):
203
273
  raise LibrusError(ErrorKind.LIMIT)
274
+ if reported is not None and reported != len(items):
275
+ raise LibrusError(ErrorKind.PARSE)
204
276
  if (items and empty) or (not items and (not empty or count != 1 or page != 0)):
205
277
  raise LibrusError(ErrorKind.PARSE)
206
278
  # An unrecognized notice next to an empty marker is not a valid empty page.
@@ -731,6 +731,8 @@ ATTENDANCE_MAX_SUBJECTS = 1024
731
731
  ATTENDANCE_DETAIL_PATH_PREFIX = "/przegladaj_nb/szczegoly/"
732
732
  # Recognition only: formative detail links are classified, never requested.
733
733
  FORMATIVE_DETAIL_PATH_PREFIX = "/przegladaj_oceny/szczegoly/ksztaltujace/"
734
+ # Recognition only: behaviour-point detail links are classified, never requested.
735
+ BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX = "/przegladaj_oceny/zachowanie_szczegoly/"
734
736
  FORMATIVE_HEADERS = (
735
737
  "przedmiot",
736
738
  "ocena kształtująca",
@@ -882,6 +884,10 @@ COMPLETED_LESSONS_MAX_PAGE_COUNT = 1000
882
884
  COMPLETED_LESSONS_MAX_PAGE_ITEMS = 256
883
885
  COMPLETED_LESSONS_MAX_BATCH_PAGES = 8
884
886
  COMPLETED_LESSONS_MAX_BATCH_ITEMS = 256
887
+ # Header labels of columns some schools add to the completed-lesson table. Their
888
+ # content is not modelled: such a column must be empty in every lesson row.
889
+ COMPLETED_LESSONS_OPTIONAL_HEADERS = frozenset({"e-Tablica"})
890
+ COMPLETED_LESSONS_COLUMNS = 7
885
891
  AGENDA_DETAIL_PATH_PREFIX = "/terminarz/szczegoly/"
886
892
  # Recognition only: closure details are not ordinary agenda-detail references.
887
893
  AGENDA_CLOSURE_DETAIL_PATH_PREFIX = "/terminarz/szczegoly_wolne/"
@@ -931,7 +937,6 @@ MODERN_DIRECTORY_QUERIES = MappingProxyType(
931
937
  "modern_recipients": (("receiverType", "parentsCouncil"),),
932
938
  }
933
939
  )
934
- MODERN_SUPPORTED_RECIPIENT_TYPE = "parentsCouncil"
935
940
  MODERN_RECIPIENT_OPERATIONS = MappingProxyType(
936
941
  {
937
942
  **{
@@ -34,7 +34,6 @@ def normalize_detail_fields(
34
34
  fields: tuple[tuple[str, str], ...],
35
35
  kind: Literal["agenda", "homework", "attendance"],
36
36
  ) -> tuple[DetailField, ...]:
37
- """Run after bounded HTML parsing, before publishing or caching a result."""
38
37
  labels: set[str] = set()
39
38
  keys: set[DetailFieldKey] = set()
40
39
  result = []
@@ -31,7 +31,6 @@ def _fail(kind: ErrorKind) -> NoReturn:
31
31
 
32
32
 
33
33
  def is_formative_table(table: html.HtmlElement) -> bool:
34
- # Cheap structural gates first: other tables' header text is never read.
35
34
  if not {"stretch", "decorated"} <= set(table.get("class", "").split()):
36
35
  return False
37
36
  heads = [row for row in markup.rows(table) if markup.in_header(row)]
@@ -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
+ BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX,
11
12
  FORMATIVE_DETAIL_PATH_PREFIX,
12
13
  GRADE_AVERAGE_HEADERS,
13
14
  GRADE_BODY_PREFIX_COLUMNS,
@@ -16,6 +17,7 @@ from librus_python_api.config import (
16
17
  GRADE_MAX_METADATA_LENGTH,
17
18
  GRADE_MAX_RECORDS,
18
19
  GRADE_MAX_SUBJECTS,
20
+ GRADE_MERGED_SUBJECTS,
19
21
  GRADE_PERIOD_HEADERS,
20
22
  GRADE_PREDICTED_ANNUAL_HEADER,
21
23
  GRADE_PREDICTED_PERIOD_HEADERS,
@@ -174,6 +176,50 @@ def _boxes(cell: html.HtmlElement) -> list[html.HtmlElement]:
174
176
  ]
175
177
 
176
178
 
179
+ def _behaviour_points(cell: html.HtmlElement, collection: _Collection) -> bool:
180
+ """Recognize and validate a behaviour-points cell; points are not grades.
181
+
182
+ The cell holds one points tooltip (starting points or a running total; its
183
+ title is not interpreted) and point entries linking to behaviour details,
184
+ each with its own unlabelled tooltip. Any other content next to the points
185
+ tooltip fails instead of being skipped.
186
+ """
187
+ tallies = [
188
+ node
189
+ for node in cell
190
+ if node.tag == "span" and "tooltip" in node.get("class", "").split()
191
+ ]
192
+ if not tallies:
193
+ return False
194
+ elements = [node for node in cell if isinstance(node.tag, str)]
195
+ boxes = [node for node in elements if node not in tallies]
196
+ if (
197
+ len(tallies) != 1
198
+ or len(tallies[0])
199
+ or not re.fullmatch(r"-?[0-9]{1,4}", markup.text(tallies[0]))
200
+ or (cell.text or "").strip()
201
+ or any((node.tail or "").strip() for node in cell)
202
+ or _boxes(cell) != boxes
203
+ ):
204
+ raise LibrusError(ErrorKind.PARSE)
205
+ for box in boxes:
206
+ anchors = [node for node in box if isinstance(node.tag, str)]
207
+ if (
208
+ box.getparent() is not cell
209
+ or len(anchors) != 1
210
+ or anchors[0].tag != "a"
211
+ or len(anchors[0])
212
+ or markup.detail_id(
213
+ anchors[0].get("href"), BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX
214
+ )
215
+ is None
216
+ or not re.fullmatch(r"[+-]?[0-9]{1,4}", markup.text(anchors[0]))
217
+ ):
218
+ raise LibrusError(ErrorKind.PARSE)
219
+ collection.consumed.add(box)
220
+ return True
221
+
222
+
177
223
  def _entries(
178
224
  cell: html.HtmlElement,
179
225
  subject: str,
@@ -320,9 +366,13 @@ def _read_numeric_table(table: html.HtmlElement, collection: _Collection) -> Non
320
366
  seen: set[html.HtmlElement] = set()
321
367
  for period, index in enumerate(current, start=1):
322
368
  if columns[index] not in seen:
323
- _entries(
324
- columns[index], subject, cast(Literal[1, 2], period), collection
325
- )
369
+ if not (
370
+ subject in GRADE_MERGED_SUBJECTS
371
+ and _behaviour_points(columns[index], collection)
372
+ ):
373
+ _entries(
374
+ columns[index], subject, cast(Literal[1, 2], period), collection
375
+ )
326
376
  seen.add(columns[index])
327
377
  for (period, kind), index in periods.items():
328
378
  cell = columns[index]
@@ -144,7 +144,6 @@ def _seen_after(
144
144
  class NotificationStore(_SQLiteStore):
145
145
  """Private native recovery store. No MCP files, auto migration or live access.
146
146
 
147
- Context locks require POSIX flock. Unsupported platforms fail explicitly.
148
147
  Raw checkpoints contain private account/content metadata, never HTTP cookies.
149
148
  """
150
149
 
@@ -1,4 +1,4 @@
1
- """Explicit optional durable sending, independent of MCP and core transport."""
1
+ """Durable sending and the notification store and workflow."""
2
2
 
3
3
  import hashlib
4
4
  import json