librus-python-api 1.0.0rc1__py3-none-any.whl

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 (46) hide show
  1. librus_python_api/__init__.py +243 -0
  2. librus_python_api/_notification_codec.py +310 -0
  3. librus_python_api/_storage.py +367 -0
  4. librus_python_api/announcements.py +159 -0
  5. librus_python_api/attachment_routes.py +114 -0
  6. librus_python_api/attachments.py +297 -0
  7. librus_python_api/attendance.py +182 -0
  8. librus_python_api/attendance_frequency.py +112 -0
  9. librus_python_api/budget.py +79 -0
  10. librus_python_api/checkpoint.py +61 -0
  11. librus_python_api/completed_lessons.py +216 -0
  12. librus_python_api/config.py +1410 -0
  13. librus_python_api/detail_fields.py +50 -0
  14. librus_python_api/diagnostics.py +25 -0
  15. librus_python_api/exceptions.py +172 -0
  16. librus_python_api/files.py +156 -0
  17. librus_python_api/grade_parsers.py +169 -0
  18. librus_python_api/grade_records.py +454 -0
  19. librus_python_api/homework_range.py +41 -0
  20. librus_python_api/lifecycle.py +24 -0
  21. librus_python_api/markup.py +147 -0
  22. librus_python_api/message_content.py +230 -0
  23. librus_python_api/messages.py +288 -0
  24. librus_python_api/models.py +1160 -0
  25. librus_python_api/modern_body.py +75 -0
  26. librus_python_api/modern_mailbox.py +459 -0
  27. librus_python_api/modern_messages.py +276 -0
  28. librus_python_api/notification_models.py +67 -0
  29. librus_python_api/notification_persistence.py +1044 -0
  30. librus_python_api/notification_workflow.py +303 -0
  31. librus_python_api/notifications.py +216 -0
  32. librus_python_api/parsers.py +232 -0
  33. librus_python_api/parsing.py +49 -0
  34. librus_python_api/persistence.py +405 -0
  35. librus_python_api/py.typed +0 -0
  36. librus_python_api/recipients.py +271 -0
  37. librus_python_api/scheduler.py +287 -0
  38. librus_python_api/school_reads.py +400 -0
  39. librus_python_api/sending.py +125 -0
  40. librus_python_api/service.py +2285 -0
  41. librus_python_api/timetable.py +261 -0
  42. librus_python_api/transport.py +956 -0
  43. librus_python_api-1.0.0rc1.dist-info/METADATA +254 -0
  44. librus_python_api-1.0.0rc1.dist-info/RECORD +46 -0
  45. librus_python_api-1.0.0rc1.dist-info/WHEEL +4 -0
  46. librus_python_api-1.0.0rc1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,61 @@
1
+ """Owned checkpoint attempts: defer cancellation, never detach persistence."""
2
+
3
+ import asyncio
4
+ import math
5
+ import time
6
+ from collections.abc import Awaitable, Callable
7
+ from contextvars import ContextVar
8
+ from dataclasses import dataclass
9
+
10
+ from librus_python_api.config import CHECKPOINT_MAX_TIMEOUT_SECONDS
11
+ from librus_python_api.exceptions import ErrorKind, LibrusError
12
+ from librus_python_api.lifecycle import join_owned
13
+
14
+ CHECKPOINT_SERVICE: ContextVar[object | None] = ContextVar(
15
+ "checkpoint_service", default=None
16
+ )
17
+
18
+
19
+ def validate_checkpoint(callback: object, seconds: float) -> None:
20
+ if (
21
+ not callable(callback)
22
+ or type(seconds) not in (int, float)
23
+ or not math.isfinite(seconds)
24
+ or not 0 < seconds <= CHECKPOINT_MAX_TIMEOUT_SECONDS
25
+ ):
26
+ raise LibrusError(ErrorKind.INVALID_INPUT)
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class CheckpointState:
31
+ failed: bool = False
32
+
33
+
34
+ async def handoff(
35
+ callback: Callable[[], Awaitable[None]], seconds: float, state: CheckpointState
36
+ ) -> None:
37
+ """The callback must cooperate with cancellation and join its own work.
38
+
39
+ If it blocks or suppresses cancellation, ownership is retained until it ends;
40
+ the library cannot make arbitrary persistence preemptible or exactly-once.
41
+ """
42
+
43
+ async def attempt() -> None:
44
+ started = time.monotonic()
45
+ try:
46
+ async with asyncio.timeout(seconds):
47
+ await callback()
48
+ if time.monotonic() - started >= seconds:
49
+ state.failed = True
50
+ except BaseException:
51
+ state.failed = True
52
+
53
+ # No cancellable await between complete receipt and establishing ownership.
54
+ owned = asyncio.create_task(attempt())
55
+ interrupted = await join_owned(owned)
56
+ if owned.cancelled():
57
+ state.failed = True
58
+ if state.failed:
59
+ raise LibrusError(ErrorKind.CHECKPOINT)
60
+ if interrupted:
61
+ raise asyncio.CancelledError
@@ -0,0 +1,216 @@
1
+ """Original bounded completed-lesson parsing and continuation integrity policy."""
2
+
3
+ import hashlib
4
+ import re
5
+ from dataclasses import astuple
6
+ from datetime import date
7
+
8
+ from lxml import html
9
+
10
+ from librus_python_api import markup
11
+ from librus_python_api.config import (
12
+ COMPLETED_LESSONS_MAX_BATCH_ITEMS,
13
+ COMPLETED_LESSONS_MAX_BATCH_PAGES,
14
+ COMPLETED_LESSONS_MAX_PAGE_COUNT,
15
+ COMPLETED_LESSONS_MAX_PAGE_ITEMS,
16
+ SCHOOL_MAX_CONTENT_LENGTH,
17
+ SCHOOL_MAX_FIELD_LENGTH,
18
+ SCHOOL_MAX_TOTAL_TEXT_LENGTH,
19
+ completed_lessons_form,
20
+ )
21
+ from librus_python_api.exceptions import ErrorKind, LibrusError
22
+ from librus_python_api.models import (
23
+ CompletedLesson,
24
+ CompletedLessonsCursor,
25
+ )
26
+ from librus_python_api.parsers import page_notices, parse_page
27
+
28
+
29
+ def validate_selection(
30
+ start: date,
31
+ end: date,
32
+ cursor: CompletedLessonsCursor | None,
33
+ max_pages: int,
34
+ limit: int,
35
+ account: str,
36
+ ) -> None:
37
+ completed_lessons_form(start, end, 0)
38
+ if (
39
+ type(max_pages) is not int
40
+ or not 1 <= max_pages <= COMPLETED_LESSONS_MAX_BATCH_PAGES
41
+ or type(limit) is not int
42
+ or not 1 <= limit <= COMPLETED_LESSONS_MAX_BATCH_ITEMS
43
+ ):
44
+ raise LibrusError(ErrorKind.INVALID_INPUT)
45
+ if cursor is None:
46
+ return
47
+ # Cursors are caller-supplied dataclasses, not authenticated opaque tokens.
48
+ if not isinstance(cursor, CompletedLessonsCursor):
49
+ raise LibrusError(ErrorKind.INVALID_INPUT)
50
+ if (
51
+ cursor.account != account
52
+ or cursor.start != start
53
+ or cursor.end != end
54
+ or type(cursor.page) is not int
55
+ or type(cursor.offset) is not int
56
+ or type(cursor.page_count) is not int
57
+ or not 1 <= cursor.page_count <= COMPLETED_LESSONS_MAX_PAGE_COUNT
58
+ or not 0 <= cursor.page < cursor.page_count
59
+ or not 0 <= cursor.offset < COMPLETED_LESSONS_MAX_PAGE_ITEMS
60
+ or (cursor.offset == 0 and cursor.page == 0)
61
+ or not isinstance(cursor.fingerprint, str)
62
+ or re.fullmatch(r"[0-9a-f]{64}", cursor.fingerprint) is None
63
+ ):
64
+ raise LibrusError(ErrorKind.INVALID_INPUT)
65
+ completed_lessons_form(cursor.start, cursor.end, cursor.page)
66
+
67
+
68
+ def _pagination(document: html.HtmlElement, page: int) -> int:
69
+ containers = [
70
+ node
71
+ for node in document.iter("div")
72
+ if "pagination" in node.get("class", "").split()
73
+ ]
74
+ if not containers:
75
+ if page != 0:
76
+ raise LibrusError(ErrorKind.PARSE)
77
+ return 1
78
+ if len(containers) != 1:
79
+ raise LibrusError(ErrorKind.PARSE)
80
+ spans = [node for node in containers[0] if node.tag == "span"]
81
+ if len(spans) != 1:
82
+ raise LibrusError(ErrorKind.PARSE)
83
+ 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)
85
+ if match is None:
86
+ raise LibrusError(ErrorKind.PARSE)
87
+ current, count = int(match[1]), int(match[2])
88
+ if count > COMPLETED_LESSONS_MAX_PAGE_COUNT:
89
+ raise LibrusError(ErrorKind.LIMIT)
90
+ if not 1 <= current <= count or current != page + 1:
91
+ raise LibrusError(ErrorKind.PARSE)
92
+ return count
93
+
94
+
95
+ def _day(value: str) -> date:
96
+ if re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value):
97
+ year, month, day = map(int, value.split("-"))
98
+ elif re.fullmatch(r"[0-9]{2}-[0-9]{2}-[0-9]{4}", value):
99
+ day, month, year = map(int, value.split("-"))
100
+ else:
101
+ raise LibrusError(ErrorKind.PARSE)
102
+ invalid = False
103
+ try:
104
+ result = date(year, month, day)
105
+ except ValueError:
106
+ invalid = True
107
+ if invalid:
108
+ raise LibrusError(ErrorKind.PARSE)
109
+ return result
110
+
111
+
112
+ def _lesson(row: html.HtmlElement, start: date, end: date) -> CompletedLesson:
113
+ cells = markup.cells(row)
114
+ if len(cells) != 7 or any(
115
+ cell.tag != "td"
116
+ or cell.get("rowspan", "1") != "1"
117
+ or cell.get("colspan", "1") != "1"
118
+ for cell in cells
119
+ ):
120
+ raise LibrusError(ErrorKind.PARSE)
121
+ date_cells = [
122
+ c for c in cells if {"center", "small"} <= set(c.get("class", "").split())
123
+ ]
124
+ weekdays = [c for c in cells if "tiny" in c.get("class", "").split()]
125
+ data = [c for c in cells if c not in date_cells and c not in weekdays]
126
+ if len(date_cells) != 1 or len(weekdays) != 1 or len(data) != 5:
127
+ raise LibrusError(ErrorKind.PARSE)
128
+ raw_day = markup.text(date_cells[0], SCHOOL_MAX_FIELD_LENGTH)
129
+ day = _day(raw_day)
130
+ if not start <= day <= end:
131
+ raise LibrusError(ErrorKind.PARSE)
132
+ values = [
133
+ markup.text(
134
+ c,
135
+ SCHOOL_MAX_CONTENT_LENGTH if i == 2 else SCHOOL_MAX_FIELD_LENGTH,
136
+ multiline=True,
137
+ )
138
+ for i, c in enumerate(data)
139
+ ]
140
+ raw_number, combined, topic, z_value, symbol = values
141
+ if raw_number not in {"", "-"} and re.fullmatch(r"[0-9]{1,2}", raw_number) is None:
142
+ raise LibrusError(ErrorKind.PARSE)
143
+ names = combined.split(", ")
144
+ subject, teacher = (names[0], names[1]) if len(names) == 2 else (combined, None)
145
+ if not subject or teacher == "":
146
+ raise LibrusError(ErrorKind.PARSE)
147
+ anchors = list(data[4].iter("a"))
148
+ if len(anchors) > 1:
149
+ raise LibrusError(ErrorKind.PARSE)
150
+ identifier = markup.attendance_detail_id(anchors[0]) if anchors else None
151
+ if anchors and identifier is None:
152
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
153
+ return CompletedLesson(
154
+ day,
155
+ raw_day,
156
+ markup.text(weekdays[0], SCHOOL_MAX_FIELD_LENGTH),
157
+ int(raw_number) if raw_number not in {"", "-"} else None,
158
+ raw_number,
159
+ subject,
160
+ teacher,
161
+ combined,
162
+ topic,
163
+ z_value,
164
+ symbol,
165
+ identifier,
166
+ )
167
+
168
+
169
+ def parse_completed_lessons(
170
+ body: bytes,
171
+ start: date,
172
+ end: date,
173
+ page: int,
174
+ ) -> tuple[tuple[CompletedLesson, ...], int, str]:
175
+ completed_lessons_form(start, end, page)
176
+ document = parse_page(body)
177
+ count = _pagination(document, page)
178
+ tables = [
179
+ t for t in document.iter("table") if "decorated" in t.get("class", "").split()
180
+ ]
181
+ empty = [
182
+ n
183
+ for n in document.iter()
184
+ if isinstance(n.tag, str) and "msgEmptyTable" in n.get("class", "").split()
185
+ ]
186
+ if len(tables) > 1 or len(empty) > 1:
187
+ raise LibrusError(ErrorKind.PARSE)
188
+ items: list[CompletedLesson] = []
189
+ total = 0
190
+ if tables:
191
+ if list(tables[0].iterdescendants("table")):
192
+ 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)
197
+ total += sum(len(v) for v in astuple(item) if isinstance(v, str))
198
+ items.append(item)
199
+ if (
200
+ len(items) > COMPLETED_LESSONS_MAX_PAGE_ITEMS
201
+ or total > SCHOOL_MAX_TOTAL_TEXT_LENGTH
202
+ ):
203
+ raise LibrusError(ErrorKind.LIMIT)
204
+ if (items and empty) or (not items and (not empty or count != 1 or page != 0)):
205
+ raise LibrusError(ErrorKind.PARSE)
206
+ # An unrecognized notice next to an empty marker is not a valid empty page.
207
+ if empty and (
208
+ not markup.text(empty[0], SCHOOL_MAX_FIELD_LENGTH) or page_notices(document)
209
+ ):
210
+ raise LibrusError(ErrorKind.PARSE)
211
+ rows = tuple(items)
212
+ # Domain values, not HTML/CSRF noise or redacted reprs, define page integrity.
213
+ fingerprint = hashlib.sha256(
214
+ repr(tuple(astuple(i) for i in rows)).encode()
215
+ ).hexdigest()
216
+ return rows, count, fingerprint