librus-python-api 1.4.0__tar.gz → 1.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/API.md +18 -3
  2. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/CHANGELOG.md +27 -0
  3. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/PKG-INFO +6 -6
  4. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/README.md +5 -5
  5. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/pyproject.toml +2 -0
  6. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/__init__.py +5 -1
  7. librus_python_api-1.6.0/src/librus_python_api/class_free_days.py +73 -0
  8. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/config.py +14 -0
  9. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/exceptions.py +7 -0
  10. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/message_content.py +18 -11
  11. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/models.py +21 -0
  12. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/service.py +42 -0
  13. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/.gitignore +0 -0
  14. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/LICENSE +0 -0
  15. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/SECURITY.md +0 -0
  16. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/_notification_bootstrap.py +0 -0
  17. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/_notification_codec.py +0 -0
  18. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/_storage.py +0 -0
  19. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/_windows_filesystem.py +0 -0
  20. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/announcements.py +0 -0
  21. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/attachment_routes.py +0 -0
  22. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/attachments.py +0 -0
  23. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/attendance.py +0 -0
  24. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/attendance_frequency.py +0 -0
  25. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/budget.py +0 -0
  26. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/checkpoint.py +0 -0
  27. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/completed_lessons.py +0 -0
  28. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/detail_fields.py +0 -0
  29. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/diagnostics.py +0 -0
  30. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/files.py +0 -0
  31. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/formative_grades.py +0 -0
  32. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/grade_parsers.py +0 -0
  33. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/grade_records.py +0 -0
  34. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/homework_range.py +0 -0
  35. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/lifecycle.py +0 -0
  36. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/markup.py +0 -0
  37. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/messages.py +0 -0
  38. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/modern_body.py +0 -0
  39. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/modern_mailbox.py +0 -0
  40. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/modern_messages.py +0 -0
  41. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/notification_models.py +0 -0
  42. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/notification_persistence.py +0 -0
  43. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/notification_workflow.py +0 -0
  44. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/notifications.py +0 -0
  45. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/parsers.py +0 -0
  46. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/parsing.py +0 -0
  47. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/persistence.py +0 -0
  48. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/py.typed +0 -0
  49. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/recipients.py +0 -0
  50. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/scheduler.py +0 -0
  51. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/school_reads.py +0 -0
  52. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/school_year_archive.py +0 -0
  53. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/sending.py +0 -0
  54. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/timetable.py +0 -0
  55. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/src/librus_python_api/transport.py +0 -0
  56. {librus_python_api-1.4.0 → librus_python_api-1.6.0}/uv.lock +0 -0
@@ -261,6 +261,17 @@ section returns `()`. See [formative grades](contracts/grades.md#formative-grade
261
261
 
262
262
  ## Timetable
263
263
 
264
+ ### Class free days
265
+
266
+ `class_free_days(*, budget=None, max_age_seconds=0.0)` returns frozen
267
+ `ClassFreeDays` with `identity`, `items` and `observation`. Each `ClassFreeDay`
268
+ contains `identifier`, `class_id`, `type_id`, civil `date_from`/`date_to` and
269
+ optional paired `lesson_no_from`/`lesson_no_to`. One bounded read-only GET;
270
+ reference URLs are not followed and type labels are not inferred. An explicit
271
+ empty collection returns `items=()`. See [contract](contracts/class-free-days.md).
272
+
273
+ ### Weekly timetable
274
+
264
275
  `timetable(monday)` takes a `date` that is a Monday and returns `Timetable` with
265
276
  seven `TimetableDay`s. Each `TimetablePeriod` has a number, a local-time
266
277
  `interval`, its `lessons` (subject and the combined teacher/classroom text),
@@ -883,12 +894,15 @@ Foreign accounts, arbitrary URLs and injected IDs fail before authentication.
883
894
  `Przeczytano` value; it is not inbox unread or per-recipient status.
884
895
  - Sent content may omit an `Adresat` field: `correspondent=None` then preserves
885
896
  absence rather than inventing a name from another table. `recipient_receipts`
886
- holds ordered `MessageRecipientReceipt(recipient, raw_status, read_timestamp)`
887
- records when a separate individual receipt table is displayed. Equal labels
897
+ holds ordered `MessageRecipientReceipt(recipient, raw_status, read_timestamp,
898
+ recipient_class)` records when a separate individual receipt table is displayed.
899
+ `recipient_class` (1.5.0) keeps the displayed class label when the table has the
900
+ middle class column, and is `None` without it or when that cell is empty.
901
+ Rows of one table must share one layout. Equal labels
888
902
  survive; no recipient IDs or aggregate read time are invented. `NIE` has no
889
903
  read timestamp; displayed dates retain school civil time. Unknown statuses fail.
890
904
  At most 256 receipt rows, 4,096 characters per field and 128 KiB total receipt
891
- text are accepted. An absent table means no reported receipts, not zero recipients.
905
+ text (class labels included) are accepted. An absent table means no reported receipts, not zero recipients.
892
906
  - `text` is full plain text with supported block/`br` boundaries and normalized
893
907
  whitespace, at most 65,536 characters. No HTML, scripts or external resources
894
908
  are returned or fetched. Active body content is unsupported, not executed.
@@ -1080,6 +1094,7 @@ text. `error_for(kind)` builds one.
1080
1094
  | `SessionExpiredError` | Proven expiry that could not be recovered |
1081
1095
  | `AccessDeniedError` | Denied, or an unexpected redirect or identity |
1082
1096
  | `ViewDisabledError` | The school administrator disabled this view |
1097
+ | `ModuleUnavailableError` | The school's Synergia variant does not include this module; exact same-origin unavailable redirect or fixed page notice, without follow/retry ([contract](contracts/module-unavailable.md)) |
1083
1098
  | `UnsupportedCapabilityError` | A recognized but unsupported layout or content |
1084
1099
  | `ParseError` | The page or JSON does not match the expected structure |
1085
1100
  | `StaleCursorError` | A previously valid continuation no longer matches the current sequence; restart explicitly |
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.0 (2026-10-09) - Class free days and capability outcomes
4
+
5
+ Compatible minor release with a new typed module-unavailable outcome. Consumers
6
+ matching error kinds exhaustively should handle `module_unavailable`.
7
+
8
+ - Add the bounded, account-isolated `class_free_days()` gateway read, frozen
9
+ `ClassFreeDays`/`ClassFreeDay` results and a guarded weekly check.
10
+ - Recognize the exact module-unavailable redirect and page notice as
11
+ `ModuleUnavailableError`, separate from disabled views and parser failures.
12
+ - Add an independent live-check freshness job to the dependency-drift workflow.
13
+ - Add reproducible targeted guard mutations and representative loopback load
14
+ measurements with scoped numerical thresholds. See [quality evidence](contracts/quality-evidence.md).
15
+
16
+ ## 1.5.0 (2026-10-08) - Sent receipts with a recipient class column
17
+
18
+ Compatible minor release. Existing fields, results and the two-column receipt
19
+ layout are unchanged.
20
+
21
+ - Read sent-message individual receipt tables that add a middle class-label
22
+ column (recipient, class, status). Such a sent open previously failed with
23
+ `UNSUPPORTED_CAPABILITY` (#60).
24
+ - Add `MessageRecipientReceipt.recipient_class` (default `None`, hidden from
25
+ repr): the displayed class label, or `None` without that column or when its
26
+ cell is empty. Consumers serializing receipts field by field see one new key.
27
+ - Rows of one receipt table must share one layout; mixed or other widths still
28
+ fail. Class labels count toward the existing receipt text bounds.
29
+
3
30
  ## 1.4.0 (2026-10-08) - Formative assessments and the observation card
4
31
 
5
32
  Compatible minor release. Existing fields, results and stored notification
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: librus-python-api
3
- Version: 1.4.0
3
+ Version: 1.6.0
4
4
  Summary: Independent bounded async Librus Synergia client
5
5
  Project-URL: Repository, https://github.com/krzysztofbury/librus-python-api
6
6
  Project-URL: Issues, https://github.com/krzysztofbury/librus-python-api/issues
@@ -42,9 +42,9 @@ private ACLs. Windows installations include `pywin32` and `tzdata` automatically
42
42
  Network paths, reparse-point paths and unsafe storage permissions fail closed.
43
43
  See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
44
44
 
45
- **Status:** `1.4.0` adds formative assessments, including the observation card,
46
- to grade results. The 1.3.0 archive read passed installed live checks for
47
- populated and empty archives on two parent logins; see [verification](VERIFICATION.md).
45
+ **Status:** `1.6.0` adds class free days and a typed module-unavailable outcome,
46
+ plus an independent live-check freshness watchdog and targeted mutation/load
47
+ evidence. See [verification](VERIFICATION.md).
48
48
  The stable 1.x API does not guarantee every school layout. School features depend
49
49
  on what each account can access. See the [compatibility policy](API.md#compatibility-policy).
50
50
  See [limitations](#supported-features-and-limitations) below.
@@ -54,7 +54,7 @@ See [limitations](#supported-features-and-limitations) below.
54
54
  Install the exact release from PyPI after its gated publication completes:
55
55
 
56
56
  ```sh
57
- python -m pip install librus-python-api==1.4.0
57
+ python -m pip install librus-python-api==1.6.0
58
58
  ```
59
59
 
60
60
  Pin the exact version qualified by your application. Before publication, use a
@@ -70,7 +70,7 @@ python -m pip install .
70
70
  To install a locally built wheel instead:
71
71
 
72
72
  ```sh
73
- python -m pip install ./dist/librus_python_api-1.4.0-py3-none-any.whl
73
+ python -m pip install ./dist/librus_python_api-1.6.0-py3-none-any.whl
74
74
  ```
75
75
 
76
76
  No CLI or background process is installed: import the library in your own program.
@@ -16,9 +16,9 @@ private ACLs. Windows installations include `pywin32` and `tzdata` automatically
16
16
  Network paths, reparse-point paths and unsafe storage permissions fail closed.
17
17
  See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
18
18
 
19
- **Status:** `1.4.0` adds formative assessments, including the observation card,
20
- to grade results. The 1.3.0 archive read passed installed live checks for
21
- populated and empty archives on two parent logins; see [verification](VERIFICATION.md).
19
+ **Status:** `1.6.0` adds class free days and a typed module-unavailable outcome,
20
+ plus an independent live-check freshness watchdog and targeted mutation/load
21
+ evidence. See [verification](VERIFICATION.md).
22
22
  The stable 1.x API does not guarantee every school layout. School features depend
23
23
  on what each account can access. See the [compatibility policy](API.md#compatibility-policy).
24
24
  See [limitations](#supported-features-and-limitations) below.
@@ -28,7 +28,7 @@ See [limitations](#supported-features-and-limitations) below.
28
28
  Install the exact release from PyPI after its gated publication completes:
29
29
 
30
30
  ```sh
31
- python -m pip install librus-python-api==1.4.0
31
+ python -m pip install librus-python-api==1.6.0
32
32
  ```
33
33
 
34
34
  Pin the exact version qualified by your application. Before publication, use a
@@ -44,7 +44,7 @@ python -m pip install .
44
44
  To install a locally built wheel instead:
45
45
 
46
46
  ```sh
47
- python -m pip install ./dist/librus_python_api-1.4.0-py3-none-any.whl
47
+ python -m pip install ./dist/librus_python_api-1.6.0-py3-none-any.whl
48
48
  ```
49
49
 
50
50
  No CLI or background process is installed: import the library in your own program.
@@ -56,6 +56,8 @@ include = ["/src", "/pyproject.toml", "/uv.lock", "/README.md", "/SECURITY.md",
56
56
  testpaths = ["tests"]
57
57
  pythonpath = ["."]
58
58
  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
59
61
  markers = [
60
62
  "performance: opt-in hardware-sensitive resource measurements",
61
63
  ]
@@ -35,6 +35,8 @@ from librus_python_api.models import (
35
35
  AttendanceView,
36
36
  AttendanceWindow,
37
37
  Availability,
38
+ ClassFreeDay,
39
+ ClassFreeDays,
38
40
  CompletedLesson,
39
41
  CompletedLessons,
40
42
  CompletedLessonsCursor,
@@ -136,7 +138,7 @@ from librus_python_api.models import (
136
138
  from librus_python_api.sending import SendAttempt
137
139
  from librus_python_api.service import AccountClient, LibrusService
138
140
 
139
- __version__ = "1.4.0"
141
+ __version__ = "1.6.0"
140
142
 
141
143
  __all__ = [
142
144
  "ArchiveAbsences",
@@ -216,6 +218,8 @@ __all__ = [
216
218
  "AccountClient",
217
219
  "AccountCredentials",
218
220
  "Availability",
221
+ "ClassFreeDay",
222
+ "ClassFreeDays",
219
223
  "Attendance",
220
224
  "Announcement",
221
225
  "Agenda",
@@ -0,0 +1,73 @@
1
+ """Bounded gateway class free days from independently observed JSON structure."""
2
+
3
+ import re
4
+ from datetime import date
5
+ from typing import Any
6
+
7
+ from librus_python_api.config import CLASS_FREE_DAYS_MAX_RECORDS
8
+ from librus_python_api.exceptions import ErrorKind, LibrusError
9
+ from librus_python_api.models import ClassFreeDay
10
+ from librus_python_api.parsers import decode_json
11
+
12
+
13
+ def _identifier(value: Any) -> str:
14
+ if type(value) is not int or value < 0 or len(str(value)) > 64:
15
+ raise LibrusError(ErrorKind.PARSE)
16
+ return str(value)
17
+
18
+
19
+ def _reference(value: Any) -> str:
20
+ if not isinstance(value, dict):
21
+ raise LibrusError(ErrorKind.PARSE)
22
+ return _identifier(value.get("Id"))
23
+
24
+
25
+ def _day(value: Any) -> date:
26
+ result = None
27
+ if type(value) is str and re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value):
28
+ try:
29
+ result = date.fromisoformat(value)
30
+ except ValueError:
31
+ pass
32
+ if result is None:
33
+ raise LibrusError(ErrorKind.PARSE)
34
+ return result
35
+
36
+
37
+ def parse_class_free_days(body: bytes) -> tuple[ClassFreeDay, ...]:
38
+ data = decode_json(body)
39
+ if not isinstance(data, dict) or not isinstance(data.get("ClassFreeDays"), list):
40
+ raise LibrusError(ErrorKind.PARSE)
41
+ rows = data["ClassFreeDays"]
42
+ if len(rows) > CLASS_FREE_DAYS_MAX_RECORDS:
43
+ raise LibrusError(ErrorKind.LIMIT)
44
+ items: list[ClassFreeDay] = []
45
+ seen: set[str] = set()
46
+ for row in rows:
47
+ if not isinstance(row, dict):
48
+ raise LibrusError(ErrorKind.PARSE)
49
+ identifier = _identifier(row.get("Id"))
50
+ start, end = _day(row.get("DateFrom")), _day(row.get("DateTo"))
51
+ first, last = row.get("LessonNoFrom"), row.get("LessonNoTo")
52
+ if "LessonNoFrom" in row or "LessonNoTo" in row:
53
+ if (
54
+ type(first) is not int
55
+ or type(last) is not int
56
+ or not 0 <= first <= last <= 99
57
+ ):
58
+ raise LibrusError(ErrorKind.PARSE)
59
+ if identifier in seen or start > end:
60
+ raise LibrusError(ErrorKind.PARSE)
61
+ seen.add(identifier)
62
+ items.append(
63
+ ClassFreeDay(
64
+ identifier,
65
+ _reference(row.get("Class")),
66
+ _reference(row.get("Type")),
67
+ start,
68
+ end,
69
+ first,
70
+ last,
71
+ )
72
+ )
73
+ return tuple(items)
@@ -449,6 +449,14 @@ ENDPOINTS: Mapping[str, Endpoint] = MappingProxyType(
449
449
  True,
450
450
  Evidence.INDEPENDENTLY_OBSERVED,
451
451
  ),
452
+ Endpoint(
453
+ "class_free_days",
454
+ "GET",
455
+ "/gateway/api/2.0/ClassFreeDays",
456
+ SideEffect.NONE,
457
+ True,
458
+ Evidence.INDEPENDENTLY_OBSERVED,
459
+ ),
452
460
  Endpoint(
453
461
  "gateway_attendance",
454
462
  "GET",
@@ -855,12 +863,18 @@ WEEKDAY_LABELS = ("pon.", "wt.", "śr.", "czw.", "pt.", "sob.", "ndz.")
855
863
  # Observed page-level notices shown instead of the requested content.
856
864
  PAGE_NOTICES = MappingProxyType(
857
865
  {
866
+ (
867
+ "Ten moduł nie jest dostępny w wykorzystywanym przez szkołę wariancie "
868
+ "rozwiązania LIBRUS Synergia."
869
+ ): ErrorKind.MODULE_UNAVAILABLE,
858
870
  "Ten widok został wyłączony przez administratora szkoły.": (
859
871
  ErrorKind.VIEW_DISABLED
860
872
  ),
861
873
  "Wybrano nieprawidłowy zakres daty.": ErrorKind.INVALID_INPUT,
862
874
  }
863
875
  )
876
+ MODULE_UNAVAILABLE_PATH = "/modul_niedostepny"
877
+ CLASS_FREE_DAYS_MAX_RECORDS = 4096
864
878
  COMPLETED_LESSONS_MAX_WINDOW_DAYS = 371
865
879
  COMPLETED_LESSONS_MAX_PAGE_COUNT = 1000
866
880
  COMPLETED_LESSONS_MAX_PAGE_ITEMS = 256
@@ -14,6 +14,7 @@ __all__ = [
14
14
  "LibrusError",
15
15
  "LimitError",
16
16
  "MaintenanceError",
17
+ "ModuleUnavailableError",
17
18
  "OperationTimeoutError",
18
19
  "ParseError",
19
20
  "SessionExpiredError",
@@ -36,6 +37,7 @@ class ErrorKind(StrEnum):
36
37
  ACCESS_DENIED = "access_denied"
37
38
  UNSUPPORTED_CAPABILITY = "unsupported_capability"
38
39
  VIEW_DISABLED = "view_disabled"
40
+ MODULE_UNAVAILABLE = "module_unavailable"
39
41
  THROTTLED = "throttled"
40
42
  MAINTENANCE = "maintenance"
41
43
  CONNECTION = "connection"
@@ -101,6 +103,10 @@ class ViewDisabledError(LibrusError):
101
103
  """The school administrator has switched this Synergia view off."""
102
104
 
103
105
 
106
+ class ModuleUnavailableError(LibrusError):
107
+ """The school's Synergia product variant does not include this module."""
108
+
109
+
104
110
  class ThrottledError(LibrusError):
105
111
  pass
106
112
 
@@ -154,6 +160,7 @@ _ERROR_TYPES: dict[ErrorKind, type[LibrusError]] = {
154
160
  ErrorKind.ACCESS_DENIED: AccessDeniedError,
155
161
  ErrorKind.UNSUPPORTED_CAPABILITY: UnsupportedCapabilityError,
156
162
  ErrorKind.VIEW_DISABLED: ViewDisabledError,
163
+ ErrorKind.MODULE_UNAVAILABLE: ModuleUnavailableError,
157
164
  ErrorKind.THROTTLED: ThrottledError,
158
165
  ErrorKind.MAINTENANCE: MaintenanceError,
159
166
  ErrorKind.CONNECTION: ConnectionError,
@@ -57,26 +57,33 @@ def _recipient_receipts(
57
57
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
58
58
  result = []
59
59
  total = 0
60
+ # Rows are recipient/status, or recipient/class/status when the page adds
61
+ # the class-label column; one table never mixes the two.
62
+ width = len(cells(table_rows[1]))
60
63
  for row in table_rows[1:]:
61
64
  columns = cells(row)
62
- if len(columns) != 2 or any(
63
- c.tag != "td"
64
- or c.get("colspan", "1") != "1"
65
- or c.get("rowspan", "1") != "1"
66
- or next(c.iterdescendants("table"), None) is not None
67
- for c in columns
65
+ if (
66
+ len(columns) != width
67
+ or width not in (2, 3)
68
+ or any(
69
+ c.tag != "td"
70
+ or c.get("colspan", "1") != "1"
71
+ or c.get("rowspan", "1") != "1"
72
+ or next(c.iterdescendants("table"), None) is not None
73
+ for c in columns
74
+ )
68
75
  ):
69
76
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
70
- recipient, status = [
71
- text(c, MESSAGE_MAX_FIELD_LENGTH, multiline=True) for c in columns
72
- ]
77
+ values = [text(c, MESSAGE_MAX_FIELD_LENGTH, multiline=True) for c in columns]
78
+ recipient, status = values[0], values[-1]
73
79
  if not recipient or not status:
74
80
  raise LibrusError(ErrorKind.PARSE)
75
- total += len(recipient) + len(status)
81
+ total += sum(map(len, values))
76
82
  if total > MESSAGE_MAX_RECEIPT_TEXT_LENGTH:
77
83
  raise LibrusError(ErrorKind.LIMIT)
78
84
  stamp = None if status == "NIE" else _timestamp(status)
79
- result.append(MessageRecipientReceipt(recipient, status, stamp))
85
+ label = values[1] if width == 3 and values[1] else None
86
+ result.append(MessageRecipientReceipt(recipient, status, stamp, label))
80
87
  return tuple(result)
81
88
 
82
89
 
@@ -16,6 +16,7 @@ from librus_python_api.exceptions import ErrorKind
16
16
 
17
17
  type OperationName = Literal[
18
18
  "identity",
19
+ "class_free_days",
19
20
  "student_information",
20
21
  "final_grades",
21
22
  "grades",
@@ -210,6 +211,8 @@ class MessageRecipientReceipt:
210
211
  recipient: str = field(repr=False)
211
212
  raw_status: str = field(repr=False)
212
213
  read_timestamp: MessageTimestamp | None = field(repr=False)
214
+ # Displayed class label of the optional middle column; None without it.
215
+ recipient_class: str | None = field(default=None, repr=False)
213
216
 
214
217
 
215
218
  @dataclass(frozen=True, slots=True)
@@ -630,6 +633,24 @@ class CompletedLessons:
630
633
  observation: "Observation"
631
634
 
632
635
 
636
+ @dataclass(frozen=True, slots=True)
637
+ class ClassFreeDay:
638
+ identifier: str = field(repr=False)
639
+ class_id: str = field(repr=False)
640
+ type_id: str = field(repr=False)
641
+ date_from: date = field(repr=False)
642
+ date_to: date = field(repr=False)
643
+ lesson_no_from: int | None = None
644
+ lesson_no_to: int | None = None
645
+
646
+
647
+ @dataclass(frozen=True, slots=True)
648
+ class ClassFreeDays:
649
+ identity: "Identity" = field(repr=False)
650
+ items: tuple[ClassFreeDay, ...] = field(repr=False)
651
+ observation: "Observation"
652
+
653
+
633
654
  class GradeKind(StrEnum):
634
655
  CURRENT = "current"
635
656
  PERIOD = "period"
@@ -27,6 +27,7 @@ from librus_python_api.attendance_frequency import (
27
27
  )
28
28
  from librus_python_api.budget import RequestBudget
29
29
  from librus_python_api.checkpoint import CHECKPOINT_SERVICE, validate_checkpoint
30
+ from librus_python_api.class_free_days import parse_class_free_days
30
31
  from librus_python_api.completed_lessons import (
31
32
  parse_completed_lessons,
32
33
  validate_selection,
@@ -42,6 +43,7 @@ from librus_python_api.config import (
42
43
  GRADE_MAX_WINDOW_DAYS,
43
44
  MESSAGE_MAX_CURSOR_IDS,
44
45
  MODERN_RECIPIENT_OPERATIONS,
46
+ MODULE_UNAVAILABLE_PATH,
45
47
  NOTIFICATION_COUNTS_ENDPOINT,
46
48
  SCHEDULE_RESPONSE_VERSION,
47
49
  SESSION_COOKIE,
@@ -83,6 +85,7 @@ from librus_python_api.models import (
83
85
  AttendanceFrequency,
84
86
  AttendanceView,
85
87
  AttendanceWindow,
88
+ ClassFreeDays,
86
89
  CompletedLesson,
87
90
  CompletedLessons,
88
91
  CompletedLessonsCursor,
@@ -1314,6 +1317,24 @@ class AccountClient:
1314
1317
  ),
1315
1318
  )
1316
1319
 
1320
+ async def class_free_days(
1321
+ self,
1322
+ *,
1323
+ budget: RequestBudget | None = None,
1324
+ max_age_seconds: float = 0.0,
1325
+ ) -> ClassFreeDays:
1326
+ """Read one bounded collection; dates are civil and references stay opaque."""
1327
+
1328
+ async def fetch(budget: RequestBudget, _: bool) -> ClassFreeDays:
1329
+ items = await self._page(
1330
+ "class_free_days", budget, parse_class_free_days, content_type=JSON
1331
+ )
1332
+ return ClassFreeDays(
1333
+ self._session_identity(), items, self._observation("class_free_days")
1334
+ )
1335
+
1336
+ return await self._read(("class_free_days",), fetch, budget, max_age_seconds)
1337
+
1317
1338
  async def attendance(
1318
1339
  self,
1319
1340
  *,
@@ -2438,6 +2459,8 @@ class AccountClient:
2438
2459
  self, response: TransportResponse, content_type: str
2439
2460
  ) -> None:
2440
2461
  if 300 <= response.status < 400:
2462
+ if self._is_module_unavailable_redirect(response):
2463
+ raise LibrusError(ErrorKind.MODULE_UNAVAILABLE)
2441
2464
  # A redirect proves expiry only when it targets a login route.
2442
2465
  # Other redirects must not trigger a new credential submission.
2443
2466
  if self._is_login_redirect(response):
@@ -2446,6 +2469,25 @@ class AccountClient:
2446
2469
  if response.status != 200 or _media_type(response) != content_type:
2447
2470
  raise LibrusError(ErrorKind.PARSE)
2448
2471
 
2472
+ def _is_module_unavailable_redirect(self, response: TransportResponse) -> bool:
2473
+ location = response.headers.get("location", "")
2474
+ if not location or len(location) > 4096:
2475
+ return False
2476
+ try:
2477
+ target = urlsplit(urljoin(response.url, location))
2478
+ source = urlsplit(response.url)
2479
+ except ValueError:
2480
+ return False
2481
+ expected = urlsplit(self._service._connection.origin(ENDPOINTS["identity"]))
2482
+ return (
2483
+ (source.scheme, source.netloc) == (expected.scheme, expected.netloc)
2484
+ and (target.scheme, target.netloc) == (expected.scheme, expected.netloc)
2485
+ and not (
2486
+ target.username or target.password or target.query or target.fragment
2487
+ )
2488
+ and target.path == MODULE_UNAVAILABLE_PATH
2489
+ )
2490
+
2449
2491
  def _is_login_redirect(self, response: TransportResponse) -> bool:
2450
2492
  location = response.headers.get("location", "")
2451
2493
  try: