librus-python-api 1.8.1__tar.gz → 1.8.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 (58) hide show
  1. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/API.md +3 -1
  2. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/CHANGELOG.md +41 -0
  3. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/PKG-INFO +7 -4
  4. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/README.md +6 -3
  5. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/pyproject.toml +2 -2
  6. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/__init__.py +3 -2
  7. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/attachments.py +5 -4
  8. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/attendance.py +13 -1
  9. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/config.py +24 -4
  10. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/exceptions.py +3 -1
  11. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/gateway_grades.py +48 -14
  12. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/grade_records.py +65 -1
  13. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/inspect_page.py +5 -2
  14. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/markup.py +17 -8
  15. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/service.py +44 -24
  16. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/transport.py +16 -10
  17. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/uv.lock +212 -212
  18. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/.gitignore +0 -0
  19. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/LICENSE +0 -0
  20. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/SECURITY.md +0 -0
  21. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/_notification_bootstrap.py +0 -0
  22. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/_notification_codec.py +0 -0
  23. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/_storage.py +0 -0
  24. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/_windows_filesystem.py +0 -0
  25. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/announcements.py +0 -0
  26. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/attachment_routes.py +0 -0
  27. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/attendance_frequency.py +0 -0
  28. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/budget.py +0 -0
  29. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/checkpoint.py +0 -0
  30. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/class_free_days.py +0 -0
  31. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/completed_lessons.py +0 -0
  32. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/detail_fields.py +0 -0
  33. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/diagnostics.py +0 -0
  34. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/files.py +0 -0
  35. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/formative_grades.py +0 -0
  36. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/grade_parsers.py +0 -0
  37. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/homework_range.py +0 -0
  38. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/lifecycle.py +0 -0
  39. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/message_content.py +0 -0
  40. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/messages.py +0 -0
  41. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/models.py +0 -0
  42. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/modern_body.py +0 -0
  43. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/modern_mailbox.py +0 -0
  44. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/modern_messages.py +0 -0
  45. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/notification_models.py +0 -0
  46. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/notification_persistence.py +0 -0
  47. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/notification_workflow.py +0 -0
  48. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/notifications.py +0 -0
  49. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/parsers.py +0 -0
  50. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/parsing.py +0 -0
  51. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/persistence.py +0 -0
  52. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/py.typed +0 -0
  53. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/recipients.py +0 -0
  54. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/scheduler.py +0 -0
  55. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/school_reads.py +0 -0
  56. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/school_year_archive.py +0 -0
  57. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/sending.py +0 -0
  58. {librus_python_api-1.8.1 → librus_python_api-1.8.3}/src/librus_python_api/timetable.py +0 -0
@@ -470,7 +470,9 @@ The initial handoff already includes that GET; subsequent sends cost one GET plu
470
470
  one POST. Expiry before dispatch raises `SessionExpiredError` and leaves
471
471
  NOT_DISPATCHED. An exact launch-to-native-login redirect is expiry, not denial.
472
472
  Messages-origin failures clear only modern binding/cookies/cache; valid legacy
473
- sessions stay usable. A later explicit modern call can rebind, but no modern
473
+ sessions stay usable. A cancelled mailbox read, or one stopped by a reached limit,
474
+ keeps them, so the next call needs no new launch; a limit that emptied the cookie
475
+ jar does not, and neither does a transport timeout. A later explicit modern call can rebind, but no modern
474
476
  read/send is automatically retried. A race after preflight can still yield UNKNOWN.
475
477
 
476
478
  - `modern_identity(*, budget=None, max_age_seconds=0)` returns `ModernIdentity`:
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.8.3 (2026-11-06) - Reported grade, attendance and announcement layouts
4
+
5
+ Thank you, @wiktorw and @Witoso, for the reports and the careful synthetic
6
+ reproductions. Signatures are unchanged.
7
+
8
+ - Announcements: "Aktualnie brak ogłoszeń" is an empty page; it used to fail
9
+ as `ParseError` and stop notification polls (#120, reported by @Witoso).
10
+ - Grades: a behaviour-row entry with a grade symbol such as `wz`, linking to
11
+ a behaviour detail, is a `NumericGrade`. Its tooltip's unlabelled first
12
+ line, such as `wzorowe`, is the `category`, as a point entry's first line
13
+ names its category; the day is `Data wystawienia` and `Dodał` stays in
14
+ `metadata`. The whole grades read used to fail (#114, reported by @wiktorw).
15
+ - Grades: a new-schema descriptive table whose semester cells already hold
16
+ plain text, such as `5` or `Brak ocen`, is read as undated semester
17
+ summaries and makes no gateway request. The all view used to fail with
18
+ `UnsupportedCapabilityError` (#115, reported by @wiktorw). Blank cells,
19
+ cells with attributes or markup, mixed layouts and filled tables with
20
+ gateway detail rows still fail.
21
+ - Grades: the new-schema table's header row is no longer read as a subject
22
+ named "Przedmiot" with "Okres 1" and "Okres 2" summaries.
23
+ - Attendance: an absence excused through the e-Usprawiedliwienia module
24
+ carries a fixed notice line in its tooltip, which failed every attendance
25
+ read with `ParseError` (observed live on 2026-11-06 on two logins). The
26
+ notice is now the metadata pair `("Usprawiedliwienie", "dodane za pomocą
27
+ modułu e-Usprawiedliwienia")`; other unlabelled lines still fail.
28
+ - `inspect_page grades` also runs the new-schema table check that `grades()`
29
+ runs in the all view, so a saved page reproduces such a failure offline.
30
+
31
+ The #114 and #115 layouts come from the reporters' synthetic examples, not an
32
+ observed page: the owner's logins have neither. Live checks show they cause no
33
+ regression, not that the fix matches every school's page.
34
+
35
+ ## 1.8.2 (2026-11-01) - Modern session kept across cancels, honest User-Agent
36
+
37
+ - A cancelled modern mailbox read or a reached limit no longer drops the modern
38
+ session, so the next call needs no new launch. Other failures still drop it,
39
+ an unrecognized body and a cookie limit, which empties the jar, included.
40
+ - The `User-Agent` carries the package version; it said 0.4 since that release.
41
+ - `config.ATTENDANCE_RESULT_CACHE_SIZE` is renamed `ACCOUNT_RESULT_CACHE_SIZE`:
42
+ it caps every cached read of an account.
43
+
3
44
  ## 1.8.1 (2026-10-29) - Session recovery, lucky number, consent error and diagnostics
4
45
 
5
46
  Signatures are unchanged; one error kind is added.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: librus-python-api
3
- Version: 1.8.1
3
+ Version: 1.8.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
@@ -42,7 +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.8.1` recovers a session Synergia no longer knows (it answers
45
+ **Status:** `1.8.3` reads behaviour grade symbols, filled descriptive
46
+ grade tables, the "Aktualnie brak ogłoszeń" page and absences excused
47
+ through e-Usprawiedliwienia. `1.8.2` keeps the modern mailbox session across a cancelled read
48
+ or a reached limit and sends its own version in the `User-Agent`. `1.8.1` recovers a session Synergia no longer knows (it answers
46
49
  with a "Brak dostępu" page, not a login redirect), reads the lucky number
47
50
  from the page header and summarizes a page that fails as a whole in
48
51
  diagnostics. `1.8.0` reports where a failure
@@ -63,7 +66,7 @@ See [limitations](#supported-features-and-limitations) below.
63
66
  Install the exact release from PyPI after its gated publication completes:
64
67
 
65
68
  ```sh
66
- python -m pip install librus-python-api==1.8.1
69
+ python -m pip install librus-python-api==1.8.3
67
70
  ```
68
71
 
69
72
  Pin the exact version qualified by your application. Before publication, use a
@@ -79,7 +82,7 @@ python -m pip install .
79
82
  To install a locally built wheel instead:
80
83
 
81
84
  ```sh
82
- python -m pip install ./dist/librus_python_api-1.8.1-py3-none-any.whl
85
+ python -m pip install ./dist/librus_python_api-1.8.3-py3-none-any.whl
83
86
  ```
84
87
 
85
88
  No console script or background process is installed: import the library in your
@@ -16,7 +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.8.1` recovers a session Synergia no longer knows (it answers
19
+ **Status:** `1.8.3` reads behaviour grade symbols, filled descriptive
20
+ grade tables, the "Aktualnie brak ogłoszeń" page and absences excused
21
+ through e-Usprawiedliwienia. `1.8.2` keeps the modern mailbox session across a cancelled read
22
+ or a reached limit and sends its own version in the `User-Agent`. `1.8.1` recovers a session Synergia no longer knows (it answers
20
23
  with a "Brak dostępu" page, not a login redirect), reads the lucky number
21
24
  from the page header and summarizes a page that fails as a whole in
22
25
  diagnostics. `1.8.0` reports where a failure
@@ -37,7 +40,7 @@ See [limitations](#supported-features-and-limitations) below.
37
40
  Install the exact release from PyPI after its gated publication completes:
38
41
 
39
42
  ```sh
40
- python -m pip install librus-python-api==1.8.1
43
+ python -m pip install librus-python-api==1.8.3
41
44
  ```
42
45
 
43
46
  Pin the exact version qualified by your application. Before publication, use a
@@ -53,7 +56,7 @@ python -m pip install .
53
56
  To install a locally built wheel instead:
54
57
 
55
58
  ```sh
56
- python -m pip install ./dist/librus_python_api-1.8.1-py3-none-any.whl
59
+ python -m pip install ./dist/librus_python_api-1.8.3-py3-none-any.whl
57
60
  ```
58
61
 
59
62
  No console script or background process is installed: import the library in your
@@ -1,5 +1,5 @@
1
1
  [build-system]
2
- requires = ["hatchling==1.32.4"]
2
+ requires = ["hatchling==1.33.0"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [project]
@@ -29,7 +29,7 @@ Issues = "https://github.com/krzysztofbury/librus-python-api/issues"
29
29
 
30
30
  [dependency-groups]
31
31
  dev = [
32
- "hatchling==1.32.4",
32
+ "hatchling==1.33.0",
33
33
  "twine>=7,<8",
34
34
  "mypy>=1.19,<3",
35
35
  "openapi-spec-validator>=0.7,<0.10",
@@ -1,5 +1,8 @@
1
1
  """Independent bounded async client. General live compatibility is unqualified."""
2
2
 
3
+ # Before the imports: config reads it for the User-Agent.
4
+ __version__ = "1.8.3"
5
+
3
6
  from librus_python_api.attachments import AttachmentStream, ModernAttachmentStream
4
7
  from librus_python_api.budget import RequestBudget
5
8
  from librus_python_api.config import (
@@ -138,8 +141,6 @@ from librus_python_api.models import (
138
141
  from librus_python_api.sending import SendAttempt
139
142
  from librus_python_api.service import AccountClient, LibrusService
140
143
 
141
- __version__ = "1.8.1"
142
-
143
144
  __all__ = [
144
145
  "ArchiveAbsences",
145
146
  "ArchiveAchievement",
@@ -15,7 +15,10 @@ from librus_python_api.attachment_routes import (
15
15
  )
16
16
  from librus_python_api.budget import RequestBudget
17
17
  from librus_python_api.config import ATTACHMENT_MAX_BYTES
18
- from librus_python_api.exceptions import ErrorKind, LibrusError, SessionExpiredError
18
+ from librus_python_api.exceptions import (
19
+ ErrorKind,
20
+ LibrusError,
21
+ )
19
22
  from librus_python_api.lifecycle import join_owned
20
23
  from librus_python_api.models import (
21
24
  AttachmentHeaders,
@@ -283,9 +286,7 @@ class ModernAttachmentStream(AttachmentStream):
283
286
  budget,
284
287
  )
285
288
  except BaseException as error:
286
- client._invalidate_modern()
287
- if isinstance(error, SessionExpiredError):
288
- error._messages_origin = True
289
+ client._modern_read_failed(error)
289
290
  raise
290
291
  await client._transport.stream_download(
291
292
  key,
@@ -11,6 +11,7 @@ from librus_python_api.config import (
11
11
  ATTENDANCE_EMPTY_MARKERS,
12
12
  ATTENDANCE_MAX_RECORDS,
13
13
  ATTENDANCE_SEMESTER_LABELS,
14
+ ATTENDANCE_TOOLTIP_NOTICES,
14
15
  GRADE_MAX_METADATA_LENGTH,
15
16
  GRADE_MAX_VALUE_LENGTH,
16
17
  )
@@ -29,6 +30,17 @@ def _entry(element: html.HtmlElement, semester: Literal[1, 2]) -> AttendanceReco
29
30
  title = element.get("title", "")
30
31
  if len(title) > GRADE_MAX_METADATA_LENGTH:
31
32
  raise LibrusError(ErrorKind.LIMIT)
33
+ # A known Librus notice is a whole line of its own; other unlabelled lines
34
+ # still fail in tooltip_fields.
35
+ notices: list[tuple[str, str]] = []
36
+ lines = []
37
+ for line in re.split(r"<br\s*/?>|\n", title, flags=re.I):
38
+ notice = ATTENDANCE_TOOLTIP_NOTICES.get(markup.tooltip_line(line))
39
+ if notice is None or notice in notices:
40
+ lines.append(line)
41
+ else:
42
+ notices.append(notice)
43
+ title = "<br>".join(lines)
32
44
  holder = html.Element("span")
33
45
  holder.set("title", re.sub(r"</b>\s*(?=<b[ >])", "</b>\n", title, flags=re.I))
34
46
  fields = markup.tooltip_fields(holder)
@@ -57,7 +69,7 @@ def _entry(element: html.HtmlElement, semester: Literal[1, 2]) -> AttendanceReco
57
69
  fields.get("Temat zajęć"),
58
70
  fields.get("Lekcja"),
59
71
  markup.attendance_detail_id(element),
60
- tuple(fields.items()),
72
+ (*fields.items(), *notices),
61
73
  )
62
74
 
63
75
 
@@ -31,6 +31,7 @@ from pydantic import (
31
31
  model_validator,
32
32
  )
33
33
 
34
+ from librus_python_api import __version__
34
35
  from librus_python_api.exceptions import ErrorKind, LibrusError
35
36
  from librus_python_api.models import (
36
37
  AttendanceView,
@@ -113,7 +114,7 @@ UPSTREAM_ORIGINS = MappingProxyType(
113
114
  OAUTH_QUERY = (("client_id", "46"),)
114
115
  SESSION_COOKIE = "oauth_token"
115
116
  AUTH_COOKIES = frozenset({SESSION_COOKIE, "DZIENNIKSID", "SDZIENNIKSID"})
116
- USER_AGENT = "librus-python-api/0.4 (independent client)"
117
+ USER_AGENT = f"librus-python-api/{__version__} (independent client)"
117
118
  ENDPOINTS: Mapping[str, Endpoint] = MappingProxyType(
118
119
  {
119
120
  item.operation_id: item
@@ -435,7 +436,7 @@ ENDPOINTS: Mapping[str, Endpoint] = MappingProxyType(
435
436
  ),
436
437
  # Gateway reads behind the grades page's new-schema descriptive
437
438
  # table. Routes and field names come from public client code and the
438
- # official page script, not from an observed populated response.
439
+ # official page script; one authorized read parsed four records.
439
440
  Endpoint(
440
441
  "descriptive_token_info",
441
442
  "GET",
@@ -786,6 +787,7 @@ GRADE_PERIOD_HEADERS = MappingProxyType(
786
787
  # semester cells and its script loads the grades from the gateway routes above.
787
788
  DESCRIPTIVE_TABLE_CLASS = "newSchemaGradesByStudentTable"
788
789
  DESCRIPTIVE_ROW_CLASS = "studentRow"
790
+ DESCRIPTIVE_DETAILS_ROW_CLASS = "studentGradesDetailsRow"
789
791
  # Gateway account and student identifiers are opaque strings, unlike every
790
792
  # other `{id}` route, which takes a numeric ID.
791
793
  DESCRIPTIVE_IDENTIFIER = re.compile(r"[A-Za-z0-9_-]{1,128}")
@@ -837,6 +839,17 @@ ATTENDANCE_SEMESTER_LABELS = MappingProxyType(
837
839
  }
838
840
  )
839
841
  ATTENDANCE_EMPTY_MARKERS = frozenset({"", "-", "Brak nieobecności"})
842
+ # Fixed unlabelled tooltip lines Librus appends to an attendance entry, and the
843
+ # metadata pair each becomes. Observed live on 2026-11-06 on two logins: an
844
+ # absence excused through the e-Usprawiedliwienia module.
845
+ ATTENDANCE_TOOLTIP_NOTICES = MappingProxyType(
846
+ {
847
+ "Usprawiedliwienie dodane za pomocą modułu e-Usprawiedliwienia": (
848
+ "Usprawiedliwienie",
849
+ "dodane za pomocą modułu e-Usprawiedliwienia",
850
+ )
851
+ }
852
+ )
840
853
  ATTENDANCE_MAX_RECORDS = 2048
841
854
  ATTENDANCE_MAX_WINDOW_DAYS = 371
842
855
  # Whole-school gateway collections resolve attendance metadata in two requests.
@@ -848,6 +861,8 @@ ATTENDANCE_DETAIL_PATH_PREFIX = "/przegladaj_nb/szczegoly/"
848
861
  FORMATIVE_DETAIL_PATH_PREFIX = "/przegladaj_oceny/szczegoly/ksztaltujace/"
849
862
  # Recognition only: behaviour-point detail links are classified, never requested.
850
863
  BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX = "/przegladaj_oceny/zachowanie_szczegoly/"
864
+ # Tooltip labels of a behaviour grade symbol entry (#114, synthetic example).
865
+ BEHAVIOUR_GRADE_LABELS = frozenset({"Data wystawienia", "Dodał", "Komentarz"})
851
866
  # Recognition only: point-grade detail links are classified, never requested.
852
867
  POINT_GRADE_DETAIL_PATH_PREFIX = "/przegladaj_oceny_punktowe/szczegoly/"
853
868
  FORMATIVE_HEADERS = (
@@ -862,9 +877,11 @@ FORMATIVE_TEMPLATE_ID = "000000"
862
877
  FORMATIVE_MAX_TEXT_LENGTH = 65536
863
878
  ATTENDANCE_DETAIL_MAX_FIELDS = 32
864
879
  ATTENDANCE_METADATA_CACHE_SIZE = 256
865
- ATTENDANCE_RESULT_CACHE_SIZE = 64
866
880
  ATTENDANCE_METADATA_TTL_SECONDS = 3600
867
881
 
882
+ # Entries of every cached read of one account, oldest first out.
883
+ ACCOUNT_RESULT_CACHE_SIZE = 64
884
+
868
885
  ANNOUNCEMENT_MAX_ITEMS = 256
869
886
  ANNOUNCEMENT_MAX_FIELD_LENGTH = 1024
870
887
  ANNOUNCEMENT_MAX_CONTENT_LENGTH = 65536
@@ -913,7 +930,10 @@ ANNOUNCEMENT_LABELS = MappingProxyType(
913
930
  ANNOUNCEMENT_EMPTY_CLASSES = frozenset(
914
931
  {"container", "border-red", "resizeable", "center"}
915
932
  )
916
- ANNOUNCEMENT_EMPTY_MARKERS = frozenset({"brak ogłoszeń", "nie ma ogłoszeń"})
933
+ # "Aktualnie brak ogłoszeń" reported in #120 on two accounts.
934
+ ANNOUNCEMENT_EMPTY_MARKERS = frozenset(
935
+ {"brak ogłoszeń", "nie ma ogłoszeń", "aktualnie brak ogłoszeń"}
936
+ )
917
937
  TIMETABLE_MAX_PERIODS = 32
918
938
  TIMETABLE_MAX_LESSONS_PER_SLOT = 16
919
939
  TIMETABLE_MAX_CHANGES_PER_SLOT = 16
@@ -136,7 +136,9 @@ class OperationTimeoutError(LibrusError):
136
136
 
137
137
 
138
138
  class LimitError(LibrusError):
139
- pass
139
+ # Library-private: the bound was the cookie count and the jar was emptied,
140
+ # so the session behind it is gone.
141
+ _cookies_cleared = False
140
142
 
141
143
 
142
144
  class ParseError(LibrusError):
@@ -3,8 +3,14 @@
3
3
  The grades page of a school on the new descriptive grading schema renders a
4
4
  subject table whose semester cells are empty; the page script then loads the
5
5
  grades from gateway JSON routes. Routes and field names come from public client
6
- code and the official page script, not from an observed populated response,
7
- so every unexpected shape fails instead of producing a partial collection.
6
+ code and the official page script. One authorized read on 2026-10-28 parsed four
7
+ PartialGrades records on one school; pagination keys are still unknown, so every
8
+ unexpected shape fails instead of producing a partial collection.
9
+
10
+ Another school renders the same table with plain text in its semester cells and
11
+ no script hooks (#115, from a reporter's synthetic example). The grade record
12
+ parser reads those cells like any descriptive row, so that table needs no
13
+ gateway read.
8
14
  """
9
15
 
10
16
  import re
@@ -19,6 +25,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_valida
19
25
 
20
26
  from librus_python_api import markup
21
27
  from librus_python_api.config import (
28
+ DESCRIPTIVE_DETAILS_ROW_CLASS,
22
29
  DESCRIPTIVE_IDENTIFIER,
23
30
  DESCRIPTIVE_MAX_NAME_LENGTH,
24
31
  DESCRIPTIVE_MAX_SUBJECTS,
@@ -330,9 +337,13 @@ def parse_gateway_users(body: bytes) -> Mapping[str, str]:
330
337
 
331
338
  @dataclass(frozen=True, slots=True)
332
339
  class DescriptiveTable:
333
- """Numeric subject ID to the subject name the grades page shows."""
340
+ """Numeric subject ID to the subject name the grades page shows.
341
+
342
+ A filled table already holds its grades and has no gateway grades.
343
+ """
334
344
 
335
345
  subjects: Mapping[int, str] = field(repr=False)
346
+ filled: bool = False
336
347
 
337
348
 
338
349
  def _classes(element: html.HtmlElement) -> set[str]:
@@ -342,9 +353,11 @@ def _classes(element: html.HtmlElement) -> set[str]:
342
353
  def read_descriptive_table(document: html.HtmlElement) -> DescriptiveTable | None:
343
354
  """The new-schema subject table, or None when the page has none.
344
355
 
345
- Its rows hold an expander, the subject and two empty semester cells that
346
- the page script fills from the gateway. A cell the page already filled
347
- would duplicate the gateway records, so it is an unsupported layout.
356
+ Its rows hold an expander, the subject and two semester cells. Either
357
+ every row has empty `gradesCell` cells that the page script fills from
358
+ the gateway, or every row has plain cells the page filled itself, which
359
+ `read_grade_records` reads and validates. A script cell the page already
360
+ filled would duplicate the gateway records, so it is unsupported.
348
361
  """
349
362
  tables = [
350
363
  table
@@ -368,20 +381,33 @@ def read_descriptive_table(document: html.HtmlElement) -> DescriptiveTable | Non
368
381
  tables = populated
369
382
  subjects: dict[int, str] = {}
370
383
  students: set[str | None] = set()
384
+ layouts: set[bool] = set()
371
385
  for row in markup.rows(tables[0]):
372
386
  if DESCRIPTIVE_ROW_CLASS not in _classes(row):
373
387
  continue
374
388
  cells = markup.cells(row)
375
- if (
376
- len(cells) != 4
377
- or not {"micro", "center", "screen-only"} <= _classes(cells[0])
378
- or [cell.get("data-semester") for cell in cells[2:]] != ["1", "2"]
379
- or not all("gradesCell" in _classes(cell) for cell in cells[2:])
389
+ if len(cells) != 4 or not {"micro", "center", "screen-only"} <= _classes(
390
+ cells[0]
380
391
  ):
381
392
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
382
- if any(
393
+ semesters = cells[2:]
394
+ # A filled cell is bare text: no attributes and no markup, so a renamed
395
+ # or moved script hook never passes as a filled table.
396
+ filled = all(
397
+ not cell.attrib
398
+ and markup.text(cell)
399
+ and not any(isinstance(node.tag, str) for node in cell)
400
+ for cell in semesters
401
+ )
402
+ if not filled and (
403
+ [cell.get("data-semester") for cell in semesters] != ["1", "2"]
404
+ or not all("gradesCell" in _classes(cell) for cell in semesters)
405
+ ):
406
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
407
+ layouts.add(filled)
408
+ if not filled and any(
383
409
  markup.text(cell) or any(isinstance(node.tag, str) for node in cell)
384
- for cell in cells[2:]
410
+ for cell in semesters
385
411
  ):
386
412
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
387
413
  identifier = row.get("data-subject_id", "")
@@ -397,7 +423,15 @@ def read_descriptive_table(document: html.HtmlElement) -> DescriptiveTable | Non
397
423
  if len(students) > 1:
398
424
  # One page shows one student; rows of several students are unknown.
399
425
  raise LibrusError(ErrorKind.PARSE)
400
- return DescriptiveTable(MappingProxyType(subjects))
426
+ if len(layouts) > 1:
427
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
428
+ if layouts == {True} and any(
429
+ DESCRIPTIVE_DETAILS_ROW_CLASS in _classes(row) for row in markup.rows(tables[0])
430
+ ):
431
+ # Detail rows are what the page script fills from the gateway; a filled
432
+ # table that still has them may also have gateway grades.
433
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
434
+ return DescriptiveTable(MappingProxyType(subjects), layouts == {True})
401
435
 
402
436
 
403
437
  def parse_grade_page(body: bytes) -> tuple[GradeRecords, DescriptiveTable | None]:
@@ -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_GRADE_LABELS,
11
12
  BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX,
12
13
  FORMATIVE_DETAIL_PATH_PREFIX,
13
14
  GRADE_AVERAGE_HEADERS,
@@ -26,6 +27,7 @@ from librus_python_api.config import (
26
27
  GRADE_PUBLICATION_DATE_LABEL,
27
28
  GRADE_PUBLICATION_PERIOD_LABELS,
28
29
  GRADE_PUBLICATION_TEACHER_LABEL,
30
+ TOOLTIP_LABELS,
29
31
  )
30
32
  from librus_python_api.exceptions import ErrorKind, LibrusError
31
33
  from librus_python_api.formative_grades import is_formative_table, read_formative_table
@@ -126,6 +128,51 @@ def _numeric(
126
128
  )
127
129
 
128
130
 
131
+ def _behaviour_grade(
132
+ element: html.HtmlElement,
133
+ subject: str,
134
+ semester: Literal[0, 1, 2],
135
+ kind: GradeKind = GradeKind.CURRENT,
136
+ ) -> NumericGrade:
137
+ """A behaviour entry with a grade symbol, such as `wz`, instead of points.
138
+
139
+ Its tooltip has the point entries' layout: an unlabelled first line, then
140
+ `Data wystawienia` and `Dodał`. The first line names the entry, as the
141
+ point category does, so it is the category. `Dodał` stays in metadata. A
142
+ label with digits, a point entry's range labels or any other label fails.
143
+ """
144
+ heading: list[str] = []
145
+ metadata = markup.tooltip_fields(element, heading)
146
+ raw = markup.text(element)
147
+ if (
148
+ not heading
149
+ # A labelled first line means the heading is missing.
150
+ or heading[0].partition(":")[0].strip() in TOOLTIP_LABELS
151
+ # Symbols carry no digits; points or a range are a point entry.
152
+ or not raw
153
+ or re.search(r"[0-9]", raw)
154
+ or not BEHAVIOUR_GRADE_LABELS >= metadata.keys()
155
+ ):
156
+ raise LibrusError(ErrorKind.PARSE)
157
+ href = element.get("href")
158
+ if href is not None and len(href) > GRADE_MAX_METADATA_LENGTH:
159
+ raise LibrusError(ErrorKind.LIMIT)
160
+ return NumericGrade(
161
+ subject,
162
+ raw,
163
+ markup.civil_date(metadata.get("Data wystawienia")),
164
+ semester,
165
+ None,
166
+ None,
167
+ heading[0],
168
+ None,
169
+ metadata.get("Komentarz"),
170
+ href,
171
+ tuple(metadata.items()),
172
+ kind,
173
+ )
174
+
175
+
129
176
  def _descriptive(
130
177
  box: html.HtmlElement,
131
178
  element: html.HtmlElement,
@@ -284,7 +331,21 @@ def _entries(
284
331
  if len(anchors) + len(descriptions) != 1:
285
332
  raise LibrusError(ErrorKind.PARSE)
286
333
  if anchors:
287
- record = _numeric(anchors[0], subject, period, kind)
334
+ behaviour = (
335
+ subject in GRADE_MERGED_SUBJECTS
336
+ and markup.detail_id(
337
+ anchors[0].get("href"), BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX
338
+ )
339
+ is not None
340
+ )
341
+ if behaviour and descriptive:
342
+ # A descriptive record has no category for the first line.
343
+ raise LibrusError(ErrorKind.PARSE)
344
+ record = (
345
+ _behaviour_grade(anchors[0], subject, period, kind)
346
+ if behaviour
347
+ else _numeric(anchors[0], subject, period, kind)
348
+ )
288
349
  if descriptive:
289
350
  href = record.href
290
351
  if href is not None and re.sub(
@@ -540,6 +601,9 @@ def read_grade_records(document: html.HtmlElement) -> GradeRecords:
540
601
  _read_numeric_table(table, collection, points=True)
541
602
  else:
542
603
  for row in markup.rows(table):
604
+ if markup.in_header(row):
605
+ # The new-schema table's column labels, not a subject.
606
+ continue
543
607
  cells = markup.cells(row)
544
608
  if _descriptive_row(cells):
545
609
  _read_descriptive_row(cells, collection)
@@ -19,8 +19,8 @@ from librus_python_api import __version__
19
19
  from librus_python_api.config import TransportLimits
20
20
  from librus_python_api.diagnostics import describe_failure, failure_frames
21
21
  from librus_python_api.exceptions import LibrusError
22
+ from librus_python_api.gateway_grades import parse_grade_page
22
23
  from librus_python_api.grade_parsers import parse_final_grades
23
- from librus_python_api.grade_records import parse_grade_records
24
24
  from librus_python_api.models import Availability
25
25
  from librus_python_api.parsers import parse_profile
26
26
 
@@ -31,7 +31,8 @@ NOTICE = (
31
31
 
32
32
 
33
33
  def _grades(body: bytes) -> dict[str, int]:
34
- records = parse_grade_records(body)
34
+ # The all view also checks the new-schema table, as `grades()` does.
35
+ records, table = parse_grade_page(body)
35
36
  return {
36
37
  "numeric": sum(1 for g in records.numeric if g.point_grade_id is None),
37
38
  "point": sum(1 for g in records.numeric if g.point_grade_id is not None),
@@ -41,6 +42,8 @@ def _grades(body: bytes) -> dict[str, int]:
41
42
  "averages": len(records.averages),
42
43
  "point_results": len(records.point_results),
43
44
  "formative": len(records.formative),
45
+ "descriptive_table": int(table is not None),
46
+ "descriptive_table_filled": int(table is not None and table.filled),
44
47
  }
45
48
 
46
49
 
@@ -81,11 +81,14 @@ def colspan(cell: html.HtmlElement) -> int:
81
81
  return span
82
82
 
83
83
 
84
- def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
84
+ def tooltip_fields(
85
+ element: html.HtmlElement, heading: list[str] | None = None
86
+ ) -> dict[str, str]:
85
87
  """Parse a 'Label: value<br>' title attribute into unique labelled fields.
86
88
 
87
89
  A free-text field may span lines; an unlabelled line after it continues
88
- its value. Any other unlabelled line is an unknown layout.
90
+ its value. Any other unlabelled line is an unknown layout. With `heading`,
91
+ the first line is taken whole, colon included, and appended to it.
89
92
  """
90
93
  title = element.get("title", "")
91
94
  if len(title) > GRADE_MAX_METADATA_LENGTH:
@@ -98,12 +101,10 @@ def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
98
101
  for chunk in chunks:
99
102
  if not chunk.strip():
100
103
  continue
101
- # Tooltips are HTML inside an attribute; parse markup only where present.
102
- rendered = (
103
- text(parse_html_document(chunk.encode()))
104
- if "<" in chunk
105
- else " ".join(chunk.split())
106
- )
104
+ rendered = tooltip_line(chunk)
105
+ if heading is not None and not heading:
106
+ heading.append(rendered)
107
+ continue
107
108
  key, separator, value = rendered.partition(":")
108
109
  key, value = key.strip(), value.strip()
109
110
  if not separator and last in TOOLTIP_MULTILINE_LABELS:
@@ -116,6 +117,14 @@ def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
116
117
  return result
117
118
 
118
119
 
120
+ def tooltip_line(chunk: str) -> str:
121
+ """One tooltip line's text, whitespace normalized."""
122
+ # Tooltips are HTML inside an attribute; parse markup only where present.
123
+ if "<" in chunk:
124
+ return text(parse_html_document(chunk.encode()))
125
+ return " ".join(chunk.split())
126
+
127
+
119
128
  def civil_date(value: str | None) -> date:
120
129
  """An ISO date, optionally followed by its weekday label in parentheses."""
121
130
  match = re.fullmatch(r"([0-9]{4}-[0-9]{2}-[0-9]{2})(?: \(([^()]+)\))?", value or "")