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.
- librus_python_api/__init__.py +243 -0
- librus_python_api/_notification_codec.py +310 -0
- librus_python_api/_storage.py +367 -0
- librus_python_api/announcements.py +159 -0
- librus_python_api/attachment_routes.py +114 -0
- librus_python_api/attachments.py +297 -0
- librus_python_api/attendance.py +182 -0
- librus_python_api/attendance_frequency.py +112 -0
- librus_python_api/budget.py +79 -0
- librus_python_api/checkpoint.py +61 -0
- librus_python_api/completed_lessons.py +216 -0
- librus_python_api/config.py +1410 -0
- librus_python_api/detail_fields.py +50 -0
- librus_python_api/diagnostics.py +25 -0
- librus_python_api/exceptions.py +172 -0
- librus_python_api/files.py +156 -0
- librus_python_api/grade_parsers.py +169 -0
- librus_python_api/grade_records.py +454 -0
- librus_python_api/homework_range.py +41 -0
- librus_python_api/lifecycle.py +24 -0
- librus_python_api/markup.py +147 -0
- librus_python_api/message_content.py +230 -0
- librus_python_api/messages.py +288 -0
- librus_python_api/models.py +1160 -0
- librus_python_api/modern_body.py +75 -0
- librus_python_api/modern_mailbox.py +459 -0
- librus_python_api/modern_messages.py +276 -0
- librus_python_api/notification_models.py +67 -0
- librus_python_api/notification_persistence.py +1044 -0
- librus_python_api/notification_workflow.py +303 -0
- librus_python_api/notifications.py +216 -0
- librus_python_api/parsers.py +232 -0
- librus_python_api/parsing.py +49 -0
- librus_python_api/persistence.py +405 -0
- librus_python_api/py.typed +0 -0
- librus_python_api/recipients.py +271 -0
- librus_python_api/scheduler.py +287 -0
- librus_python_api/school_reads.py +400 -0
- librus_python_api/sending.py +125 -0
- librus_python_api/service.py +2285 -0
- librus_python_api/timetable.py +261 -0
- librus_python_api/transport.py +956 -0
- librus_python_api-1.0.0rc1.dist-info/METADATA +254 -0
- librus_python_api-1.0.0rc1.dist-info/RECORD +46 -0
- librus_python_api-1.0.0rc1.dist-info/WHEEL +4 -0
- 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
|