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,261 @@
|
|
|
1
|
+
"""Independently authored bounded timetable parser with explicit week identity."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from datetime import date, time, timedelta
|
|
6
|
+
|
|
7
|
+
from lxml import html
|
|
8
|
+
|
|
9
|
+
from librus_python_api import markup
|
|
10
|
+
from librus_python_api.config import (
|
|
11
|
+
TIMETABLE_DATE_ATTRIBUTE,
|
|
12
|
+
TIMETABLE_END_ATTRIBUTE,
|
|
13
|
+
TIMETABLE_MAX_CHANGES_PER_SLOT,
|
|
14
|
+
TIMETABLE_MAX_LESSONS_PER_SLOT,
|
|
15
|
+
TIMETABLE_MAX_PERIODS,
|
|
16
|
+
TIMETABLE_START_ATTRIBUTE,
|
|
17
|
+
timetable_form,
|
|
18
|
+
)
|
|
19
|
+
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
20
|
+
from librus_python_api.models import (
|
|
21
|
+
TimetableChange,
|
|
22
|
+
TimetableDay,
|
|
23
|
+
TimetableInterval,
|
|
24
|
+
TimetableLesson,
|
|
25
|
+
TimetablePeriod,
|
|
26
|
+
)
|
|
27
|
+
from librus_python_api.parsers import parse_page
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _clock(value: str | None) -> time:
|
|
31
|
+
if not re.fullmatch(r"[0-9]{2}:[0-9]{2}", value or ""):
|
|
32
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
33
|
+
failed = False
|
|
34
|
+
try:
|
|
35
|
+
return time.fromisoformat(value or "")
|
|
36
|
+
except ValueError:
|
|
37
|
+
failed = True
|
|
38
|
+
assert failed
|
|
39
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _interval(start: str | None, end: str | None) -> TimetableInterval:
|
|
43
|
+
first, last = _clock(start), _clock(end)
|
|
44
|
+
if first >= last:
|
|
45
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
46
|
+
return TimetableInterval(first, last)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _lesson(element: html.HtmlElement) -> TimetableLesson:
|
|
50
|
+
subjects = list(element.iter("b"))
|
|
51
|
+
if len(subjects) != 1:
|
|
52
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
53
|
+
subject, rendered = markup.text(subjects[0]), markup.text(element)
|
|
54
|
+
if not subject or not rendered.startswith(subject):
|
|
55
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
56
|
+
remainder = rendered[len(subject) :].strip()
|
|
57
|
+
if remainder and not remainder.startswith("-"):
|
|
58
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
59
|
+
teacher = remainder.removeprefix("-").strip() or None
|
|
60
|
+
return TimetableLesson(subject, teacher)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _change(
|
|
64
|
+
element: html.HtmlElement, wrapper: html.HtmlElement | None
|
|
65
|
+
) -> TimetableChange:
|
|
66
|
+
"""A notice; its tooltip anchor sits inside it or wraps it."""
|
|
67
|
+
label = markup.text(element)
|
|
68
|
+
anchors = list(element.iter("a")) + ([wrapper] if wrapper is not None else [])
|
|
69
|
+
if not label or len(anchors) > 1:
|
|
70
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
71
|
+
if anchors and anchors[0].get("title") is None:
|
|
72
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
73
|
+
metadata = markup.tooltip_fields(anchors[0]) if anchors else {}
|
|
74
|
+
if any(len(key) > 1024 or len(value) > 1024 for key, value in metadata.items()):
|
|
75
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
76
|
+
return TimetableChange(label, tuple(metadata.items()))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _content(
|
|
80
|
+
cell: html.HtmlElement,
|
|
81
|
+
) -> tuple[tuple[TimetableLesson, ...], tuple[TimetableChange, ...]]:
|
|
82
|
+
if list(cell.iter("table")):
|
|
83
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
84
|
+
lessons: list[TimetableLesson] = []
|
|
85
|
+
changes: list[TimetableChange] = []
|
|
86
|
+
consumed: set[html.HtmlElement] = set()
|
|
87
|
+
for node in cell.iter("div"):
|
|
88
|
+
classes = set(node.get("class", "").split())
|
|
89
|
+
if "text" in classes or {"center", "plan-lekcji-info"} <= classes:
|
|
90
|
+
parent = node.getparent()
|
|
91
|
+
wrapper = (
|
|
92
|
+
parent
|
|
93
|
+
if "text" not in classes
|
|
94
|
+
and parent is not None
|
|
95
|
+
and parent is not cell
|
|
96
|
+
and parent.tag == "a"
|
|
97
|
+
and len(parent) == 1
|
|
98
|
+
# A pure wrapper only: text beside the notice must not vanish.
|
|
99
|
+
and not (parent.text or "").strip()
|
|
100
|
+
and not (node.tail or "").strip()
|
|
101
|
+
else None
|
|
102
|
+
)
|
|
103
|
+
descendants = set(node.iter()) | (
|
|
104
|
+
{wrapper} if wrapper is not None else set()
|
|
105
|
+
)
|
|
106
|
+
if descendants.intersection(consumed):
|
|
107
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
108
|
+
consumed.update(descendants)
|
|
109
|
+
if "text" in classes:
|
|
110
|
+
lessons.append(_lesson(node))
|
|
111
|
+
else:
|
|
112
|
+
changes.append(_change(node, wrapper))
|
|
113
|
+
if (
|
|
114
|
+
len(lessons) > TIMETABLE_MAX_LESSONS_PER_SLOT
|
|
115
|
+
or len(changes) > TIMETABLE_MAX_CHANGES_PER_SLOT
|
|
116
|
+
):
|
|
117
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
118
|
+
for node in cell.iter():
|
|
119
|
+
if not isinstance(node.tag, str):
|
|
120
|
+
continue
|
|
121
|
+
if node not in consumed and (
|
|
122
|
+
(node.text or "").strip() or (node.tag == "a" and node.get("title"))
|
|
123
|
+
):
|
|
124
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
125
|
+
if (node.tail or "").strip() and node.getparent() not in consumed:
|
|
126
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
127
|
+
return tuple(lessons), tuple(changes)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _slot(
|
|
131
|
+
cell: html.HtmlElement, number: int, monday: date
|
|
132
|
+
) -> tuple[int, TimetablePeriod]:
|
|
133
|
+
if cell.get("colspan", "1") != "1" or cell.get("rowspan", "1") != "1":
|
|
134
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
135
|
+
value = cell.get(TIMETABLE_DATE_ATTRIBUTE, "")
|
|
136
|
+
if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value):
|
|
137
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
138
|
+
failed = False
|
|
139
|
+
day = None
|
|
140
|
+
try:
|
|
141
|
+
day = date.fromisoformat(value)
|
|
142
|
+
except ValueError:
|
|
143
|
+
failed = True
|
|
144
|
+
if failed or day is None or not 0 <= (day - monday).days < 7:
|
|
145
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
146
|
+
interval = _interval(
|
|
147
|
+
cell.get(TIMETABLE_START_ATTRIBUTE), cell.get(TIMETABLE_END_ATTRIBUTE)
|
|
148
|
+
)
|
|
149
|
+
lessons, changes = _content(cell)
|
|
150
|
+
return (day - monday).days, TimetablePeriod(
|
|
151
|
+
number, interval, lessons, changes, None
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _period(row: html.HtmlElement, monday: date) -> tuple[TimetablePeriod, ...]:
|
|
156
|
+
cells = markup.cells(row)
|
|
157
|
+
slots = [cell for cell in cells if cell.get("id") == "timetableEntryBox"]
|
|
158
|
+
numbers = [
|
|
159
|
+
cell
|
|
160
|
+
for cell in cells
|
|
161
|
+
if cell.tag == "td"
|
|
162
|
+
and cell not in slots
|
|
163
|
+
and "center" in cell.get("class", "").split()
|
|
164
|
+
and re.fullmatch(r"[0-9]{1,2}", markup.text(cell))
|
|
165
|
+
]
|
|
166
|
+
if len(slots) != 7 or not 1 <= len(numbers) <= 2:
|
|
167
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
168
|
+
values = {markup.text(cell) for cell in numbers}
|
|
169
|
+
if len(values) != 1:
|
|
170
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
171
|
+
raw_number = values.pop()
|
|
172
|
+
selected: dict[int, TimetablePeriod] = {}
|
|
173
|
+
for cell in slots:
|
|
174
|
+
offset, period = _slot(cell, int(raw_number), monday)
|
|
175
|
+
if offset in selected:
|
|
176
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
177
|
+
selected[offset] = period
|
|
178
|
+
return tuple(selected[index] for index in range(7))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _recess(
|
|
182
|
+
row: html.HtmlElement, previous: tuple[TimetablePeriod, ...]
|
|
183
|
+
) -> tuple[TimetablePeriod, ...]:
|
|
184
|
+
cells = [
|
|
185
|
+
cell
|
|
186
|
+
for cell in markup.cells(row)
|
|
187
|
+
if "center" in cell.get("class", "").split()
|
|
188
|
+
and re.fullmatch(
|
|
189
|
+
r"[0-9]{2}:[0-9]{2}\s*-\s*[0-9]{2}:[0-9]{2}", markup.text(cell)
|
|
190
|
+
)
|
|
191
|
+
]
|
|
192
|
+
if (
|
|
193
|
+
len(cells) != 1
|
|
194
|
+
or not previous
|
|
195
|
+
or any(p.next_recess is not None for p in previous)
|
|
196
|
+
):
|
|
197
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
198
|
+
start, separator, end = markup.text(cells[0]).partition("-")
|
|
199
|
+
if not separator:
|
|
200
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
201
|
+
# Upstream recess clocks are reported values, not guaranteed positive gaps.
|
|
202
|
+
# Preserve valid clocks without inventing a duration or rejecting a week.
|
|
203
|
+
interval = TimetableInterval(_clock(start.strip()), _clock(end.strip()))
|
|
204
|
+
return tuple(replace(period, next_recess=interval) for period in previous)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _grid(document: html.HtmlElement) -> html.HtmlElement:
|
|
208
|
+
tables = [
|
|
209
|
+
t
|
|
210
|
+
for t in document.iter("table")
|
|
211
|
+
if {"decorated", "plan-lekcji"} <= set(t.get("class", "").split())
|
|
212
|
+
]
|
|
213
|
+
if len(tables) != 1:
|
|
214
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
215
|
+
table = tables[0]
|
|
216
|
+
for node in document.iter():
|
|
217
|
+
if not isinstance(node.tag, str):
|
|
218
|
+
continue
|
|
219
|
+
known = node.get("id") == "timetableEntryBox" or (
|
|
220
|
+
node.tag == "div" and "plan-lekcji-info" in node.get("class", "").split()
|
|
221
|
+
)
|
|
222
|
+
if known and next(node.iterancestors("table"), None) is not table:
|
|
223
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
224
|
+
return table
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def parse_timetable(body: bytes, monday: date) -> tuple[TimetableDay, ...]:
|
|
228
|
+
timetable_form(monday)
|
|
229
|
+
# The upstream repeats this particular slot ID. Other duplicate IDs and
|
|
230
|
+
# parser repairs remain errors; semantic slot uniqueness is checked below.
|
|
231
|
+
table = _grid(parse_page(body, repeatable_id="timetableEntryBox"))
|
|
232
|
+
periods: list[tuple[TimetablePeriod, ...]] = []
|
|
233
|
+
for row in markup.rows(table):
|
|
234
|
+
classes = set(row.get("class", "").split())
|
|
235
|
+
if "line1" in classes:
|
|
236
|
+
current = _period(row, monday)
|
|
237
|
+
if periods:
|
|
238
|
+
for previous, following in zip(periods[-1], current, strict=True):
|
|
239
|
+
earliest = previous.interval.ends_at
|
|
240
|
+
if (
|
|
241
|
+
following.number <= previous.number
|
|
242
|
+
or following.interval.starts_at < earliest
|
|
243
|
+
):
|
|
244
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
245
|
+
periods.append(current)
|
|
246
|
+
if len(periods) > TIMETABLE_MAX_PERIODS:
|
|
247
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
248
|
+
elif "line0" in classes:
|
|
249
|
+
if not periods:
|
|
250
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
251
|
+
periods[-1] = _recess(row, periods[-1])
|
|
252
|
+
elif any(cell.get("id") == "timetableEntryBox" for cell in markup.cells(row)):
|
|
253
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
254
|
+
if not periods:
|
|
255
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
256
|
+
return tuple(
|
|
257
|
+
TimetableDay(
|
|
258
|
+
monday + timedelta(days=index), tuple(row[index] for row in periods)
|
|
259
|
+
)
|
|
260
|
+
for index in range(7)
|
|
261
|
+
)
|