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,337 @@
1
+ """Optional native category orchestration with durable at-least-once replay."""
2
+
3
+ import secrets
4
+ from dataclasses import asdict
5
+ from datetime import date, datetime, timedelta
6
+ from zoneinfo import ZoneInfo
7
+
8
+ from librus_python_api._notification_codec import (
9
+ META_BYTES,
10
+ canonical_notification_id,
11
+ dump,
12
+ encode_batch,
13
+ )
14
+ from librus_python_api.budget import RequestBudget
15
+ from librus_python_api.checkpoint import validate_checkpoint
16
+ from librus_python_api.config import homework_form
17
+ from librus_python_api.exceptions import ErrorKind, LibrusError
18
+ from librus_python_api.models import (
19
+ AttendanceView,
20
+ GradeView,
21
+ MessageFolder,
22
+ MessagingBackend,
23
+ NotificationCategory,
24
+ ScheduleEventResponse,
25
+ ScheduleEvents,
26
+ )
27
+ from librus_python_api.notification_models import (
28
+ NotificationBatch,
29
+ NotificationItem,
30
+ NotificationState,
31
+ NotificationValue,
32
+ )
33
+ from librus_python_api.notification_persistence import NotificationStore
34
+ from librus_python_api.service import AccountClient
35
+
36
+
37
+ class NotificationWorkflow:
38
+ """Explicit selected-category poll and acknowledgement, without MCP schemas.
39
+
40
+ A pending batch is returned without network until explicitly acknowledged.
41
+ Replay of raw schedule bytes requires no fresh consume consent. New read-once
42
+ retrieval requires allow_consume_events=True every time; no hidden retry.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ client: AccountClient,
48
+ store: NotificationStore,
49
+ *,
50
+ messages_backend: MessagingBackend = MessagingBackend.LEGACY,
51
+ ) -> None:
52
+ if (
53
+ not isinstance(client, AccountClient)
54
+ or not isinstance(store, NotificationStore)
55
+ or not isinstance(messages_backend, MessagingBackend)
56
+ ):
57
+ raise LibrusError(ErrorKind.INVALID_INPUT)
58
+ self.client, self.store = client, store
59
+ self._messages_backend = messages_backend
60
+
61
+ @property
62
+ def messages_backend(self) -> MessagingBackend:
63
+ return self._messages_backend
64
+
65
+ async def poll(
66
+ self,
67
+ *,
68
+ categories: tuple[NotificationCategory, ...],
69
+ allow_consume_events: bool = False,
70
+ homework_window: tuple[date, date] | None = None,
71
+ checkpoint_timeout_seconds: float = 5.0,
72
+ budget: RequestBudget | None = None,
73
+ ) -> NotificationBatch:
74
+ if (
75
+ type(categories) is not tuple
76
+ or not 1 <= len(categories) <= 6
77
+ or any(
78
+ not isinstance(category, NotificationCategory)
79
+ for category in categories
80
+ )
81
+ or len(set(categories)) != len(categories)
82
+ or type(allow_consume_events) is not bool
83
+ or (budget is not None and not isinstance(budget, RequestBudget))
84
+ ):
85
+ raise LibrusError(ErrorKind.INVALID_INPUT)
86
+ validate_checkpoint(self.store._checkpoint, checkpoint_timeout_seconds)
87
+ if homework_window is None:
88
+ today = datetime.now(ZoneInfo("Europe/Warsaw")).date()
89
+ homework_window = (today - timedelta(days=7), today)
90
+ if type(homework_window) is not tuple or len(homework_window) != 2:
91
+ raise LibrusError(ErrorKind.INVALID_INPUT)
92
+ homework_form(*homework_window)
93
+ actual = budget if budget is not None else self.client._service._budget()
94
+ actual._bind_loop()
95
+ actual.remaining_seconds()
96
+ window = homework_window
97
+ return await self.store._transaction(
98
+ self.client.context,
99
+ lambda: self._poll(
100
+ categories,
101
+ allow_consume_events,
102
+ window,
103
+ checkpoint_timeout_seconds,
104
+ actual,
105
+ ),
106
+ )
107
+
108
+ async def acknowledge(self, receipt: str) -> None:
109
+ await self.store.acknowledge(receipt, context=self.client.context)
110
+
111
+ async def _poll(
112
+ self,
113
+ categories: tuple[NotificationCategory, ...],
114
+ consume: bool,
115
+ window: tuple[date, date],
116
+ seconds: float,
117
+ budget: RequestBudget,
118
+ ) -> NotificationBatch:
119
+ context, store = self.client.context, self.store
120
+ pending, raw, uncertain = await store._io(lambda: store._pending(context))
121
+ if pending is not None:
122
+ if pending.categories != categories or (
123
+ NotificationCategory.MESSAGES in categories
124
+ and pending.messages_backend is not self.messages_backend
125
+ ):
126
+ raise LibrusError(ErrorKind.INVALID_INPUT)
127
+ return pending
128
+ agenda = NotificationCategory.AGENDA in categories
129
+ if agenda and raw is None:
130
+ if uncertain:
131
+ raise LibrusError(ErrorKind.CHECKPOINT)
132
+ if not consume:
133
+ raise LibrusError(ErrorKind.INVALID_INPUT)
134
+ state = await store._io(lambda: store._read_state(context.identifier))
135
+ ordinary = await self._ordinary(categories, window, budget)
136
+ # Validate whole ordinary results and state capacity before consuming.
137
+ items = self._new_items(ordinary, state)
138
+ if len(items) > store.limits.batch_items:
139
+ raise LibrusError(ErrorKind.LIMIT)
140
+ for entry in state.seen:
141
+ if (
142
+ len(entry.identifiers)
143
+ + sum(item.category == entry.category for item in items)
144
+ > store.limits.seen_ids_per_category
145
+ ):
146
+ raise LibrusError(ErrorKind.LIMIT)
147
+ provisional = NotificationBatch(
148
+ "0" * 64,
149
+ context,
150
+ not state.initialized,
151
+ categories,
152
+ items,
153
+ False,
154
+ self.messages_backend
155
+ if NotificationCategory.MESSAGES in categories
156
+ else MessagingBackend.LEGACY,
157
+ )
158
+ encode_batch(provisional, store.limits.batch_bytes)
159
+ events: ScheduleEvents | None = None
160
+ if agenda and raw is None:
161
+ reserve = min(
162
+ self.client._service._transport_limits.response_max_bytes,
163
+ budget.remaining_response_bytes,
164
+ )
165
+ await store._io(lambda: store._reserve(context.identifier, reserve))
166
+
167
+ async def persist(response: ScheduleEventResponse) -> None:
168
+ await store._io(
169
+ lambda: store._checkpoint(context, response), finishing=True
170
+ )
171
+
172
+ events = await self.client.consume_schedule_events(
173
+ checkpoint=persist,
174
+ allow_consume_events=True,
175
+ checkpoint_timeout_seconds=seconds,
176
+ budget=budget,
177
+ )
178
+ _, raw, uncertain = await store._io(lambda: store._pending(context))
179
+ assert raw is not None and not uncertain
180
+ raw_identifier: str | None = None
181
+ cursor: int | None = None
182
+ total: int | None = None
183
+ more = False
184
+ if agenda:
185
+ assert raw is not None
186
+ if events is None:
187
+ events = await self.client.decode_schedule_events(raw[1], budget=budget)
188
+ if raw[3] is not None and raw[3] != len(events.items):
189
+ raise LibrusError(ErrorKind.PARSE)
190
+ schedule, cursor = self._slice_schedule(
191
+ events, raw[2], state, store.limits.batch_items - len(items)
192
+ )
193
+ items += schedule
194
+ raw_identifier, total = raw[0], len(events.items)
195
+ more = cursor < total
196
+ batch = NotificationBatch(
197
+ secrets.token_hex(32),
198
+ context,
199
+ not state.initialized,
200
+ categories,
201
+ items,
202
+ more,
203
+ self.messages_backend
204
+ if NotificationCategory.MESSAGES in categories
205
+ else MessagingBackend.LEGACY,
206
+ )
207
+ await store._io(
208
+ lambda: store._stage(batch, raw_identifier, cursor, total), finishing=True
209
+ )
210
+ return batch
211
+
212
+ async def _ordinary(
213
+ self,
214
+ categories: tuple[NotificationCategory, ...],
215
+ window: tuple[date, date],
216
+ budget: RequestBudget,
217
+ ) -> tuple[NotificationItem, ...]:
218
+ items = []
219
+ for category in categories:
220
+ values: tuple[NotificationValue, ...]
221
+ if category is NotificationCategory.AGENDA:
222
+ continue
223
+ if category is NotificationCategory.GRADES:
224
+ grades = await self.client.grades(
225
+ view=GradeView.LAST_LOGIN, budget=budget
226
+ )
227
+ values = (*grades.records.numeric, *grades.records.descriptive)
228
+ identity, observation = grades.identity, grades.observation
229
+ elif category is NotificationCategory.ATTENDANCE:
230
+ attendance = await self.client.attendance(
231
+ view=AttendanceView.LAST_LOGIN, budget=budget
232
+ )
233
+ values, identity, observation = (
234
+ attendance.items,
235
+ attendance.identity,
236
+ attendance.observation,
237
+ )
238
+ elif category is NotificationCategory.MESSAGES:
239
+ messages = (
240
+ await self.client.modern_messages_page(
241
+ MessageFolder.RECEIVED, budget=budget
242
+ )
243
+ if self.messages_backend is MessagingBackend.MODERN
244
+ else await self.client.messages_page(
245
+ MessageFolder.RECEIVED, budget=budget
246
+ )
247
+ )
248
+ values, identity, observation = (
249
+ messages.items,
250
+ messages.identity,
251
+ messages.observation,
252
+ )
253
+ elif category is NotificationCategory.ANNOUNCEMENTS:
254
+ announcements = await self.client.announcements(budget=budget)
255
+ values, identity, observation = (
256
+ announcements.items,
257
+ announcements.identity,
258
+ announcements.observation,
259
+ )
260
+ else:
261
+ assert category is NotificationCategory.HOMEWORK
262
+ homework = await self.client.homework(*window, budget=budget)
263
+ values, identity, observation = (
264
+ homework.items,
265
+ homework.identity,
266
+ homework.observation,
267
+ )
268
+ for value in values:
269
+ items.append(
270
+ NotificationItem(
271
+ category,
272
+ canonical_notification_id(category, value),
273
+ value,
274
+ identity,
275
+ observation,
276
+ )
277
+ )
278
+ return tuple(items)
279
+
280
+ @staticmethod
281
+ def _new_items(
282
+ items: tuple[NotificationItem, ...], state: NotificationState
283
+ ) -> tuple[NotificationItem, ...]:
284
+ seen = {entry.category: set(entry.identifiers) for entry in state.seen}
285
+ result = []
286
+ for item in items:
287
+ if item.identifier not in seen[item.category]:
288
+ result.append(item)
289
+ seen[item.category].add(item.identifier)
290
+ return tuple(result)
291
+
292
+ def _slice_schedule(
293
+ self,
294
+ events: ScheduleEvents,
295
+ cursor: int,
296
+ state: NotificationState,
297
+ available: int,
298
+ ) -> tuple[tuple[NotificationItem, ...], int]:
299
+ if not 0 <= cursor <= len(events.items):
300
+ raise LibrusError(ErrorKind.PARSE)
301
+ seen = {
302
+ identifier
303
+ for entry in state.seen
304
+ if entry.category is NotificationCategory.AGENDA
305
+ for identifier in entry.identifiers
306
+ }
307
+ items: list[NotificationItem] = []
308
+ used = 2
309
+ for index in range(cursor, len(events.items)):
310
+ value = events.items[index]
311
+ identifier = canonical_notification_id(NotificationCategory.AGENDA, value)
312
+ if identifier in seen:
313
+ cursor = index + 1
314
+ continue
315
+ if len(items) >= min(self.store.limits.replay_events, available):
316
+ break
317
+ encoded = dump(asdict(value), META_BYTES)
318
+ size = used + len(encoded) + bool(items)
319
+ if size > self.store.limits.replay_bytes:
320
+ if not items:
321
+ raise LibrusError(ErrorKind.LIMIT)
322
+ break
323
+ items.append(
324
+ NotificationItem(
325
+ NotificationCategory.AGENDA,
326
+ identifier,
327
+ value,
328
+ events.identity,
329
+ events.observation,
330
+ )
331
+ )
332
+ seen.add(identifier)
333
+ used = size
334
+ cursor = index + 1
335
+ if not items and cursor < len(events.items):
336
+ raise LibrusError(ErrorKind.LIMIT)
337
+ return tuple(items), cursor
@@ -0,0 +1,216 @@
1
+ """Original bounded count/event parsers; no seen state, hashing or persistence."""
2
+
3
+ import re
4
+ import zlib
5
+
6
+ from librus_python_api import markup
7
+ from librus_python_api.config import (
8
+ NOTIFICATION_DESTINATIONS,
9
+ NOTIFICATION_MAX_COUNT,
10
+ NOTIFICATION_MAX_MENU_ITEMS,
11
+ SCHEDULE_EMPTY_LABEL,
12
+ SCHEDULE_EVENT_HEADERS,
13
+ SCHEDULE_MAX_EVENTS,
14
+ SCHEDULE_RESPONSE_VERSION,
15
+ SCHOOL_MAX_CONTENT_LENGTH,
16
+ SCHOOL_MAX_FIELD_LENGTH,
17
+ SCHOOL_MAX_TOTAL_TEXT_LENGTH,
18
+ )
19
+ from librus_python_api.exceptions import ErrorKind, LibrusError
20
+ from librus_python_api.models import (
21
+ Identity,
22
+ NotificationCategory,
23
+ NotificationCount,
24
+ Observation,
25
+ Person,
26
+ RecentScheduleEvent,
27
+ ScheduleEventResponse,
28
+ ScheduleEventWire,
29
+ )
30
+ from librus_python_api.parsers import parse_page
31
+
32
+
33
+ def parse_notification_counts(body: bytes) -> tuple[NotificationCount, ...]:
34
+ root = parse_page(body)
35
+ menus = root.xpath("//div[@id='graphic-menu']/ul")
36
+ if len(menus) != 1:
37
+ raise LibrusError(ErrorKind.PARSE)
38
+ rows = menus[0].xpath("./li")
39
+ if len(rows) > NOTIFICATION_MAX_MENU_ITEMS:
40
+ raise LibrusError(ErrorKind.LIMIT)
41
+ items = []
42
+ seen = set()
43
+ for row in rows:
44
+ links = row.xpath("./a")
45
+ if len(links) > 4:
46
+ raise LibrusError(ErrorKind.LIMIT)
47
+ known = [
48
+ a
49
+ for a in links
50
+ if a.get("href") in NOTIFICATION_DESTINATIONS
51
+ and "counter" not in a.get("class", "").split()
52
+ ]
53
+ if not known:
54
+ continue
55
+ if len(known) != 1:
56
+ raise LibrusError(ErrorKind.PARSE)
57
+ category = NotificationCategory(NOTIFICATION_DESTINATIONS[known[0].get("href")])
58
+ if category in seen:
59
+ raise LibrusError(ErrorKind.PARSE)
60
+ seen.add(category)
61
+ counters = [a for a in links if "counter" in a.get("class", "").split()]
62
+ marked = [
63
+ e
64
+ for e in row.iterdescendants()
65
+ if isinstance(e.tag, str) and "counter" in e.get("class", "").split()
66
+ ]
67
+ if len(marked) != len(counters):
68
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
69
+ if len(counters) > 1:
70
+ raise LibrusError(ErrorKind.PARSE)
71
+ count = 0
72
+ if counters:
73
+ value = markup.text(counters[0], 16)
74
+ if re.fullmatch(r"[0-9]{1,7}", value) is None:
75
+ raise LibrusError(ErrorKind.PARSE)
76
+ count = int(value)
77
+ if count > NOTIFICATION_MAX_COUNT:
78
+ raise LibrusError(ErrorKind.LIMIT)
79
+ label = markup.text(known[0], SCHOOL_MAX_FIELD_LENGTH)
80
+ if not label:
81
+ raise LibrusError(ErrorKind.PARSE)
82
+ items.append(NotificationCount(category, label, count))
83
+ if not items:
84
+ raise LibrusError(ErrorKind.PARSE)
85
+ return tuple(items)
86
+
87
+
88
+ def parse_schedule_events(body: bytes) -> tuple[RecentScheduleEvent, ...]:
89
+ root = parse_page(body)
90
+ containers = root.xpath(
91
+ "//div[contains(concat(' ',normalize-space(@class),' '),"
92
+ "' container-background ')]"
93
+ )
94
+ if len(containers) != 1:
95
+ raise LibrusError(ErrorKind.PARSE)
96
+ tables = containers[0].xpath(".//table")
97
+ empty = containers[0].xpath(".//p[@class='msgEmptyTable']")
98
+ if tables and empty:
99
+ raise LibrusError(ErrorKind.PARSE)
100
+ if len(tables) != 1:
101
+ empty = containers[0].xpath("./p[@class='msgEmptyTable']")
102
+ if (
103
+ not tables
104
+ and len(empty) == 1
105
+ and markup.text(empty[0]) == SCHEDULE_EMPTY_LABEL
106
+ ):
107
+ return ()
108
+ raise LibrusError(ErrorKind.PARSE)
109
+ rows = list(markup.rows(tables[0]))
110
+ if len(rows) > SCHEDULE_MAX_EVENTS + 1:
111
+ raise LibrusError(ErrorKind.LIMIT)
112
+ header = markup.cells(rows[0]) if rows else []
113
+ if (
114
+ not rows
115
+ or any(markup.colspan(c) != 1 for c in header)
116
+ or tuple(markup.text(c).casefold() for c in header) != SCHEDULE_EVENT_HEADERS
117
+ ):
118
+ raise LibrusError(ErrorKind.PARSE)
119
+ items = []
120
+ total = 0
121
+ for row in rows[1:]:
122
+ cells = markup.cells(row)
123
+ if (
124
+ len(cells) != 4
125
+ or any(c.tag != "td" or markup.colspan(c) != 1 for c in cells)
126
+ or markup.in_header(row)
127
+ ):
128
+ raise LibrusError(ErrorKind.PARSE)
129
+ # Display row numbers are not durable identities.
130
+ values = [markup.text(c, SCHOOL_MAX_FIELD_LENGTH) for c in cells[:3]]
131
+ if tuple(value.casefold() for value in values) == SCHEDULE_EVENT_HEADERS[:3]:
132
+ raise LibrusError(ErrorKind.PARSE)
133
+ data = markup.text(cells[3], SCHOOL_MAX_CONTENT_LENGTH, multiline=True)
134
+ if not values[1] or not values[2]:
135
+ raise LibrusError(ErrorKind.PARSE)
136
+ total += sum(map(len, values)) + len(data)
137
+ if total > SCHOOL_MAX_TOTAL_TEXT_LENGTH:
138
+ raise LibrusError(ErrorKind.LIMIT)
139
+ items.append(RecentScheduleEvent(values[1], values[2], data))
140
+ return tuple(items)
141
+
142
+
143
+ def validate_response(
144
+ response: ScheduleEventResponse, account: str, wire_limit: int
145
+ ) -> None:
146
+ if (
147
+ not isinstance(response, ScheduleEventResponse)
148
+ or type(response.version) is not int
149
+ or response.version != SCHEDULE_RESPONSE_VERSION
150
+ ):
151
+ raise LibrusError(ErrorKind.INVALID_INPUT)
152
+ wire = response.wire
153
+ if (
154
+ not isinstance(wire, ScheduleEventWire)
155
+ or type(wire.body) is not bytes
156
+ or len(wire.body) > wire_limit
157
+ ):
158
+ raise LibrusError(ErrorKind.INVALID_INPUT)
159
+ if (
160
+ not isinstance(response.identity, Identity)
161
+ or not isinstance(response.identity.observation, Observation)
162
+ or not isinstance(response.identity.owner, Person)
163
+ or not isinstance(response.identity.student, Person)
164
+ or not isinstance(response.observation, Observation)
165
+ or response.observation.account != account
166
+ or response.identity.observation.account != account
167
+ or response.observation.source != "consume_schedule_events"
168
+ ):
169
+ raise LibrusError(ErrorKind.INVALID_INPUT)
170
+ for values in (wire.content_codings, wire.transfer_codings):
171
+ if (
172
+ type(values) is not tuple
173
+ or len(values) > 128
174
+ or any(type(v) is not str or len(v) > 8192 for v in values)
175
+ ):
176
+ raise LibrusError(ErrorKind.INVALID_INPUT)
177
+ if wire.content_type is not None and (
178
+ type(wire.content_type) is not str or len(wire.content_type) > 8192
179
+ ):
180
+ raise LibrusError(ErrorKind.INVALID_INPUT)
181
+
182
+
183
+ def decode_payload(body: bytes, wire: ScheduleEventWire, limit: int) -> bytes:
184
+ if (wire.content_type or "").split(";", 1)[0].strip().casefold() != "text/html":
185
+ raise LibrusError(ErrorKind.PARSE)
186
+ if (
187
+ len(wire.content_codings) > 1
188
+ or any(
189
+ v.strip().casefold() not in ("identity", "gzip")
190
+ for v in wire.content_codings
191
+ )
192
+ or len(wire.transfer_codings) > 1
193
+ or any(v.strip().casefold() != "chunked" for v in wire.transfer_codings)
194
+ ):
195
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
196
+ if (
197
+ not wire.content_codings
198
+ or wire.content_codings[0].strip().casefold() == "identity"
199
+ ):
200
+ if len(body) > limit:
201
+ raise LibrusError(ErrorKind.LIMIT)
202
+ return body
203
+ failed = False
204
+ decoded = b""
205
+ try:
206
+ inflater = zlib.decompressobj(16 + zlib.MAX_WBITS)
207
+ decoded = inflater.decompress(body, limit + 1)
208
+ if len(decoded) > limit:
209
+ raise LibrusError(ErrorKind.LIMIT)
210
+ if not inflater.eof or inflater.unused_data:
211
+ failed = True
212
+ except zlib.error:
213
+ failed = True
214
+ if failed:
215
+ raise LibrusError(ErrorKind.PARSE)
216
+ return decoded