librus-python-api 1.5.0__tar.gz → 1.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/API.md +12 -0
  2. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/CHANGELOG.md +23 -0
  3. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/PKG-INFO +6 -6
  4. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/README.md +5 -5
  5. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/pyproject.toml +2 -0
  6. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/__init__.py +5 -1
  7. librus_python_api-1.6.1/src/librus_python_api/class_free_days.py +73 -0
  8. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/config.py +18 -0
  9. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/exceptions.py +7 -0
  10. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/markup.py +4 -2
  11. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/models.py +19 -0
  12. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/school_reads.py +18 -3
  13. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/service.py +42 -0
  14. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/.gitignore +0 -0
  15. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/LICENSE +0 -0
  16. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/SECURITY.md +0 -0
  17. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/_notification_bootstrap.py +0 -0
  18. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/_notification_codec.py +0 -0
  19. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/_storage.py +0 -0
  20. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/_windows_filesystem.py +0 -0
  21. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/announcements.py +0 -0
  22. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/attachment_routes.py +0 -0
  23. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/attachments.py +0 -0
  24. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/attendance.py +0 -0
  25. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/attendance_frequency.py +0 -0
  26. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/budget.py +0 -0
  27. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/checkpoint.py +0 -0
  28. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/completed_lessons.py +0 -0
  29. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/detail_fields.py +0 -0
  30. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/diagnostics.py +0 -0
  31. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/files.py +0 -0
  32. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/formative_grades.py +0 -0
  33. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/grade_parsers.py +0 -0
  34. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/grade_records.py +0 -0
  35. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/homework_range.py +0 -0
  36. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/lifecycle.py +0 -0
  37. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/message_content.py +0 -0
  38. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/messages.py +0 -0
  39. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/modern_body.py +0 -0
  40. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/modern_mailbox.py +0 -0
  41. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/modern_messages.py +0 -0
  42. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/notification_models.py +0 -0
  43. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/notification_persistence.py +0 -0
  44. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/notification_workflow.py +0 -0
  45. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/notifications.py +0 -0
  46. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/parsers.py +0 -0
  47. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/parsing.py +0 -0
  48. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/persistence.py +0 -0
  49. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/py.typed +0 -0
  50. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/recipients.py +0 -0
  51. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/scheduler.py +0 -0
  52. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/school_year_archive.py +0 -0
  53. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/sending.py +0 -0
  54. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/timetable.py +0 -0
  55. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/src/librus_python_api/transport.py +0 -0
  56. {librus_python_api-1.5.0 → librus_python_api-1.6.1}/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),
@@ -1083,6 +1094,7 @@ text. `error_for(kind)` builds one.
1083
1094
  | `SessionExpiredError` | Proven expiry that could not be recovered |
1084
1095
  | `AccessDeniedError` | Denied, or an unexpected redirect or identity |
1085
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)) |
1086
1098
  | `UnsupportedCapabilityError` | A recognized but unsupported layout or content |
1087
1099
  | `ParseError` | The page or JSON does not match the expected structure |
1088
1100
  | `StaleCursorError` | A previously valid continuation no longer matches the current sequence; restart explicitly |
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.1 (2026-10-12) - Sunday grade dates and calendar closures
4
+
5
+ - Accept both observed Sunday tooltip labels, `nd.` and `ndz.`, while retaining
6
+ the original grade metadata and strict rejection of unknown date suffixes.
7
+ - Preserve calendar closure events whose handler targets the separate closure
8
+ details page. Such events retain their text and date with `reference=None`;
9
+ they are never misrouted to ordinary agenda details or fetched automatically.
10
+ - Add regressions based on guarded structure-only observations with independently
11
+ authored text and IDs. Unknown closure handlers still fail closed.
12
+
13
+ ## 1.6.0 (2026-10-09) - Class free days and capability outcomes
14
+
15
+ Compatible minor release with a new typed module-unavailable outcome. Consumers
16
+ matching error kinds exhaustively should handle `module_unavailable`.
17
+
18
+ - Add the bounded, account-isolated `class_free_days()` gateway read, frozen
19
+ `ClassFreeDays`/`ClassFreeDay` results and a guarded weekly check.
20
+ - Recognize the exact module-unavailable redirect and page notice as
21
+ `ModuleUnavailableError`, separate from disabled views and parser failures.
22
+ - Add an independent live-check freshness job to the dependency-drift workflow.
23
+ - Add reproducible targeted guard mutations and representative loopback load
24
+ measurements with scoped numerical thresholds. See [quality evidence](contracts/quality-evidence.md).
25
+
3
26
  ## 1.5.0 (2026-10-08) - Sent receipts with a recipient class column
4
27
 
5
28
  Compatible minor release. Existing fields, results and the two-column receipt
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: librus-python-api
3
- Version: 1.5.0
3
+ Version: 1.6.1
4
4
  Summary: Independent bounded async Librus Synergia client
5
5
  Project-URL: Repository, https://github.com/krzysztofbury/librus-python-api
6
6
  Project-URL: Issues, https://github.com/krzysztofbury/librus-python-api/issues
@@ -42,9 +42,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.5.0` reads sent-message receipt tables that show a recipient
46
- class column. `1.4.0` added formative assessments, including the observation
47
- card, to grade results; see [verification](VERIFICATION.md).
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
47
+ remain available. 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.5.0
57
+ python -m pip install librus-python-api==1.6.1
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.5.0-py3-none-any.whl
73
+ python -m pip install ./dist/librus_python_api-1.6.1-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.5.0` reads sent-message receipt tables that show a recipient
20
- class column. `1.4.0` added formative assessments, including the observation
21
- card, to grade results; see [verification](VERIFICATION.md).
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
21
+ remain available. 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.5.0
31
+ python -m pip install librus-python-api==1.6.1
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.5.0-py3-none-any.whl
47
+ python -m pip install ./dist/librus_python_api-1.6.1-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.5.0"
141
+ __version__ = "1.6.1"
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",
@@ -852,21 +860,31 @@ HOMEWORK_MARK_DONE_HANDLER = (
852
860
  r"\s*showConfirmQuestion\(\s*[0-9]{1,64}\s*,\s*[0-9]{1,64}\s*\);?\s*"
853
861
  )
854
862
  WEEKDAY_LABELS = ("pon.", "wt.", "śr.", "czw.", "pt.", "sob.", "ndz.")
863
+ # Grade tooltips also use the short Sunday display label.
864
+ CIVIL_DATE_WEEKDAY_LABELS = frozenset((*WEEKDAY_LABELS, "nd."))
855
865
  # Observed page-level notices shown instead of the requested content.
856
866
  PAGE_NOTICES = MappingProxyType(
857
867
  {
868
+ (
869
+ "Ten moduł nie jest dostępny w wykorzystywanym przez szkołę wariancie "
870
+ "rozwiązania LIBRUS Synergia."
871
+ ): ErrorKind.MODULE_UNAVAILABLE,
858
872
  "Ten widok został wyłączony przez administratora szkoły.": (
859
873
  ErrorKind.VIEW_DISABLED
860
874
  ),
861
875
  "Wybrano nieprawidłowy zakres daty.": ErrorKind.INVALID_INPUT,
862
876
  }
863
877
  )
878
+ MODULE_UNAVAILABLE_PATH = "/modul_niedostepny"
879
+ CLASS_FREE_DAYS_MAX_RECORDS = 4096
864
880
  COMPLETED_LESSONS_MAX_WINDOW_DAYS = 371
865
881
  COMPLETED_LESSONS_MAX_PAGE_COUNT = 1000
866
882
  COMPLETED_LESSONS_MAX_PAGE_ITEMS = 256
867
883
  COMPLETED_LESSONS_MAX_BATCH_PAGES = 8
868
884
  COMPLETED_LESSONS_MAX_BATCH_ITEMS = 256
869
885
  AGENDA_DETAIL_PATH_PREFIX = "/terminarz/szczegoly/"
886
+ # Recognition only: closure details are not ordinary agenda-detail references.
887
+ AGENDA_CLOSURE_DETAIL_PATH_PREFIX = "/terminarz/szczegoly_wolne/"
870
888
  HOMEWORK_DETAIL_PATH_PREFIX = "/moje_zadania/podglad/"
871
889
  MESSAGE_MAX_PAGE_COUNT = 1000
872
890
  MESSAGE_MAX_PAGE_ITEMS = 50
@@ -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,
@@ -9,11 +9,11 @@ from lxml import etree, html
9
9
 
10
10
  from librus_python_api.config import (
11
11
  ATTENDANCE_DETAIL_PATH_PREFIX,
12
+ CIVIL_DATE_WEEKDAY_LABELS,
12
13
  GRADE_MAX_COLUMNS,
13
14
  GRADE_MAX_METADATA_FIELDS,
14
15
  GRADE_MAX_METADATA_LENGTH,
15
16
  UPSTREAM_ORIGINS,
16
- WEEKDAY_LABELS,
17
17
  )
18
18
  from librus_python_api.exceptions import ErrorKind, LibrusError
19
19
  from librus_python_api.parsers import parse_html_document
@@ -109,7 +109,9 @@ def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
109
109
  def civil_date(value: str | None) -> date:
110
110
  """An ISO date, optionally followed by its weekday label in parentheses."""
111
111
  match = re.fullmatch(r"([0-9]{4}-[0-9]{2}-[0-9]{2})(?: \(([^()]+)\))?", value or "")
112
- if match is None or (match[2] is not None and match[2] not in WEEKDAY_LABELS):
112
+ if match is None or (
113
+ match[2] is not None and match[2] not in CIVIL_DATE_WEEKDAY_LABELS
114
+ ):
113
115
  raise LibrusError(ErrorKind.PARSE)
114
116
  try:
115
117
  return date.fromisoformat(match[1])
@@ -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",
@@ -632,6 +633,24 @@ class CompletedLessons:
632
633
  observation: "Observation"
633
634
 
634
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
+
635
654
  class GradeKind(StrEnum):
636
655
  CURRENT = "current"
637
656
  PERIOD = "period"
@@ -9,6 +9,7 @@ from lxml import html
9
9
 
10
10
  from librus_python_api import markup
11
11
  from librus_python_api.config import (
12
+ AGENDA_CLOSURE_DETAIL_PATH_PREFIX,
12
13
  AGENDA_DESCRIPTION_LABEL,
13
14
  AGENDA_DETAIL_PATH_PREFIX,
14
15
  AGENDA_TOOLTIP_LABELS,
@@ -42,13 +43,14 @@ def school_reference(
42
43
  ) -> SchoolReference | None:
43
44
  """Find the single detail link.
44
45
 
45
- Agenda cells must not carry any other handler. Homework rows also carry a
46
- mark-done button; the library never follows handlers, so it is inert here.
46
+ Closure handlers stay inert without an ordinary agenda-detail reference.
47
+ Homework rows also carry a mark-done button; handlers are never followed.
47
48
  """
48
49
  prefix = (
49
50
  AGENDA_DETAIL_PATH_PREFIX if kind == "agenda" else HOMEWORK_DETAIL_PATH_PREFIX
50
51
  )
51
52
  found: set[str] = set()
53
+ closure = False
52
54
  for node in nodes:
53
55
  if not isinstance(node.tag, str):
54
56
  continue
@@ -57,6 +59,19 @@ def school_reference(
57
59
  raise LibrusError(ErrorKind.LIMIT)
58
60
  if not action:
59
61
  continue
62
+ if (
63
+ kind == "agenda"
64
+ and node is nodes[0]
65
+ and node.tag == "td"
66
+ and re.fullmatch(
67
+ r"location\.href='"
68
+ + re.escape(AGENDA_CLOSURE_DETAIL_PATH_PREFIX)
69
+ + r"[0-9]{1,64}'",
70
+ action,
71
+ )
72
+ ):
73
+ closure = True
74
+ continue
60
75
  if kind == "homework" and prefix not in action:
61
76
  # Only the observed mark-done button is inert; anything else is new.
62
77
  if re.fullmatch(HOMEWORK_MARK_DONE_HANDLER, action):
@@ -66,7 +81,7 @@ def school_reference(
66
81
  if len(paths) != 1:
67
82
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
68
83
  found.add(paths[0].removeprefix(prefix))
69
- if len(found) > 1:
84
+ if len(found) > 1 or (closure and found):
70
85
  raise LibrusError(ErrorKind.PARSE)
71
86
  return SchoolReference(kind, next(iter(found)), account) if found else None
72
87
 
@@ -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: