librus-python-api 1.0.0__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 (48) hide show
  1. librus_python_api/__init__.py +243 -0
  2. librus_python_api/_notification_bootstrap.py +124 -0
  3. librus_python_api/_notification_codec.py +396 -0
  4. librus_python_api/_storage.py +403 -0
  5. librus_python_api/_windows_filesystem.py +390 -0
  6. librus_python_api/announcements.py +159 -0
  7. librus_python_api/attachment_routes.py +114 -0
  8. librus_python_api/attachments.py +297 -0
  9. librus_python_api/attendance.py +182 -0
  10. librus_python_api/attendance_frequency.py +112 -0
  11. librus_python_api/budget.py +79 -0
  12. librus_python_api/checkpoint.py +61 -0
  13. librus_python_api/completed_lessons.py +216 -0
  14. librus_python_api/config.py +1410 -0
  15. librus_python_api/detail_fields.py +50 -0
  16. librus_python_api/diagnostics.py +25 -0
  17. librus_python_api/exceptions.py +172 -0
  18. librus_python_api/files.py +242 -0
  19. librus_python_api/grade_parsers.py +169 -0
  20. librus_python_api/grade_records.py +454 -0
  21. librus_python_api/homework_range.py +41 -0
  22. librus_python_api/lifecycle.py +24 -0
  23. librus_python_api/markup.py +147 -0
  24. librus_python_api/message_content.py +230 -0
  25. librus_python_api/messages.py +288 -0
  26. librus_python_api/models.py +1160 -0
  27. librus_python_api/modern_body.py +75 -0
  28. librus_python_api/modern_mailbox.py +459 -0
  29. librus_python_api/modern_messages.py +276 -0
  30. librus_python_api/notification_models.py +145 -0
  31. librus_python_api/notification_persistence.py +1243 -0
  32. librus_python_api/notification_workflow.py +337 -0
  33. librus_python_api/notifications.py +216 -0
  34. librus_python_api/parsers.py +232 -0
  35. librus_python_api/parsing.py +49 -0
  36. librus_python_api/persistence.py +419 -0
  37. librus_python_api/py.typed +0 -0
  38. librus_python_api/recipients.py +271 -0
  39. librus_python_api/scheduler.py +287 -0
  40. librus_python_api/school_reads.py +400 -0
  41. librus_python_api/sending.py +125 -0
  42. librus_python_api/service.py +2285 -0
  43. librus_python_api/timetable.py +261 -0
  44. librus_python_api/transport.py +956 -0
  45. librus_python_api-1.0.0.dist-info/METADATA +262 -0
  46. librus_python_api-1.0.0.dist-info/RECORD +48 -0
  47. librus_python_api-1.0.0.dist-info/WHEEL +4 -0
  48. librus_python_api-1.0.0.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
+ )