librus-python-api 1.0.0rc1__py3-none-any.whl → 1.0.1__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.
@@ -120,7 +120,7 @@ from librus_python_api.models import (
120
120
  from librus_python_api.sending import SendAttempt
121
121
  from librus_python_api.service import AccountClient, LibrusService
122
122
 
123
- __version__ = "1.0.0rc1"
123
+ __version__ = "1.0.1"
124
124
 
125
125
  __all__ = [
126
126
  "AccountContext",
@@ -0,0 +1,124 @@
1
+ """Original bounded validation of caller-established, neutral offline mappings."""
2
+
3
+ from dataclasses import asdict
4
+
5
+ from librus_python_api._notification_codec import (
6
+ HEX,
7
+ canonical_notification_id,
8
+ dump,
9
+ validate_historical_item,
10
+ )
11
+ from librus_python_api.exceptions import ErrorKind, LibrusError
12
+ from librus_python_api.models import NotificationCategory, RecentScheduleEvent
13
+ from librus_python_api.notification_models import (
14
+ NotificationBaselineMapping,
15
+ NotificationBootstrap,
16
+ NotificationItem,
17
+ NotificationProvenance,
18
+ NotificationSeen,
19
+ NotificationState,
20
+ )
21
+
22
+
23
+ def prepare_bootstrap(
24
+ plan: NotificationBootstrap, per_category: int, batch_items: int, batch_bytes: int
25
+ ) -> tuple[
26
+ NotificationState,
27
+ tuple[NotificationItem, ...],
28
+ tuple[NotificationBaselineMapping, ...],
29
+ ]:
30
+ if type(plan.mappings) is not tuple or type(plan.pending_events) is not tuple:
31
+ raise LibrusError(ErrorKind.INVALID_INPUT)
32
+ if (
33
+ len(plan.mappings) > len(NotificationCategory) * per_category
34
+ or len(plan.pending_events) > batch_items
35
+ ):
36
+ raise LibrusError(ErrorKind.LIMIT)
37
+ seen, unmapped = _mapped_state(plan.mappings, per_category)
38
+ items: list[NotificationItem] = []
39
+ used = 2
40
+ agenda_ids = set(
41
+ next(
42
+ entry.identifiers
43
+ for entry in seen
44
+ if entry.category is NotificationCategory.AGENDA
45
+ )
46
+ )
47
+ for event in plan.pending_events:
48
+ if type(event) is not RecentScheduleEvent:
49
+ raise LibrusError(ErrorKind.INVALID_INPUT)
50
+ item = NotificationItem(
51
+ NotificationCategory.AGENDA,
52
+ "0" * 64,
53
+ event,
54
+ None,
55
+ None,
56
+ NotificationProvenance.IMPORTED_HISTORY,
57
+ )
58
+ try:
59
+ validate_historical_item(item)
60
+ except LibrusError:
61
+ raise LibrusError(ErrorKind.INVALID_INPUT) from None
62
+ identifier = canonical_notification_id(NotificationCategory.AGENDA, event)
63
+ if identifier in agenda_ids:
64
+ # A conflict cannot silently consume an externally pending event.
65
+ raise LibrusError(ErrorKind.INVALID_INPUT)
66
+ agenda_ids.add(identifier)
67
+ final = NotificationItem(
68
+ item.category, identifier, event, None, None, item.provenance
69
+ )
70
+ used += len(
71
+ dump({"kind": "RecentScheduleEvent", "item": asdict(final)}, batch_bytes)
72
+ ) + bool(items)
73
+ if used > batch_bytes:
74
+ raise LibrusError(ErrorKind.LIMIT)
75
+ items.append(final)
76
+ if len(agenda_ids) > per_category:
77
+ raise LibrusError(ErrorKind.LIMIT)
78
+ return NotificationState(True, seen), tuple(items), unmapped
79
+
80
+
81
+ def _mapped_state(
82
+ mappings: tuple[NotificationBaselineMapping, ...], per_category: int
83
+ ) -> tuple[tuple[NotificationSeen, ...], tuple[NotificationBaselineMapping, ...]]:
84
+ seen: dict[NotificationCategory, list[str]] = {
85
+ category: [] for category in NotificationCategory
86
+ }
87
+ sources: set[tuple[NotificationCategory, str]] = set()
88
+ native: set[tuple[NotificationCategory, str]] = set()
89
+ unmapped = []
90
+ counts = {category: 0 for category in NotificationCategory}
91
+ for mapping in mappings:
92
+ if (
93
+ type(mapping) is not NotificationBaselineMapping
94
+ or not isinstance(mapping.category, NotificationCategory)
95
+ or type(mapping.source_identifier) is not str
96
+ or not 1 <= len(mapping.source_identifier) <= 256
97
+ ):
98
+ raise LibrusError(ErrorKind.INVALID_INPUT)
99
+ try:
100
+ mapping.source_identifier.encode("utf-8")
101
+ except UnicodeError:
102
+ raise LibrusError(ErrorKind.INVALID_INPUT) from None
103
+ source = (mapping.category, mapping.source_identifier)
104
+ if source in sources:
105
+ raise LibrusError(ErrorKind.INVALID_INPUT)
106
+ sources.add(source)
107
+ counts[mapping.category] += 1
108
+ if counts[mapping.category] > per_category:
109
+ raise LibrusError(ErrorKind.LIMIT)
110
+ identifier = mapping.native_identifier
111
+ if identifier is None:
112
+ unmapped.append(mapping)
113
+ continue
114
+ if type(identifier) is not str or HEX.fullmatch(identifier) is None:
115
+ raise LibrusError(ErrorKind.INVALID_INPUT)
116
+ key = (mapping.category, identifier)
117
+ if key in native:
118
+ raise LibrusError(ErrorKind.INVALID_INPUT)
119
+ native.add(key)
120
+ seen[mapping.category].append(identifier)
121
+ return tuple(
122
+ NotificationSeen(category, tuple(identifiers))
123
+ for category, identifiers in seen.items()
124
+ ), tuple(unmapped)
@@ -10,6 +10,7 @@ from typing import Any, cast
10
10
 
11
11
  from pydantic import TypeAdapter, ValidationError
12
12
 
13
+ from librus_python_api.config import SCHOOL_MAX_CONTENT_LENGTH, SCHOOL_MAX_FIELD_LENGTH
13
14
  from librus_python_api.exceptions import ErrorKind, LibrusError
14
15
  from librus_python_api.models import (
15
16
  Announcement,
@@ -18,6 +19,8 @@ from librus_python_api.models import (
18
19
  HomeworkItem,
19
20
  Identity,
20
21
  MessageSummary,
22
+ MessagingBackend,
23
+ ModernMessageSummary,
21
24
  NotificationCategory,
22
25
  NumericGrade,
23
26
  Observation,
@@ -28,6 +31,7 @@ from librus_python_api.models import (
28
31
  from librus_python_api.notification_models import (
29
32
  NotificationBatch,
30
33
  NotificationItem,
34
+ NotificationProvenance,
31
35
  NotificationSeen,
32
36
  NotificationState,
33
37
  NotificationValue,
@@ -45,6 +49,7 @@ _VALUE_TYPES: dict[str, type[Any]] = {
45
49
  DescriptiveGrade,
46
50
  AttendanceRecord,
47
51
  MessageSummary,
52
+ ModernMessageSummary,
48
53
  Announcement,
49
54
  HomeworkItem,
50
55
  RecentScheduleEvent,
@@ -53,7 +58,7 @@ _VALUE_TYPES: dict[str, type[Any]] = {
53
58
  _CATEGORY_TYPES: dict[NotificationCategory, tuple[type[Any], ...]] = {
54
59
  NotificationCategory.GRADES: (NumericGrade, DescriptiveGrade),
55
60
  NotificationCategory.ATTENDANCE: (AttendanceRecord,),
56
- NotificationCategory.MESSAGES: (MessageSummary,),
61
+ NotificationCategory.MESSAGES: (MessageSummary, ModernMessageSummary),
57
62
  NotificationCategory.ANNOUNCEMENTS: (Announcement,),
58
63
  NotificationCategory.HOMEWORK: (HomeworkItem,),
59
64
  NotificationCategory.AGENDA: (RecentScheduleEvent,),
@@ -144,6 +149,14 @@ def canonical_notification_id(
144
149
  if isinstance(value, RecentScheduleEvent):
145
150
  # Independently specified three-field canonical identity, including data.
146
151
  canonical: Any = asdict(value)
152
+ elif isinstance(value, ModernMessageSummary):
153
+ canonical = [
154
+ 2,
155
+ category.value,
156
+ MessagingBackend.MODERN.value,
157
+ value.reference.folder.value,
158
+ value.reference.identifier,
159
+ ]
147
160
  elif isinstance(value, MessageSummary):
148
161
  canonical = [
149
162
  1,
@@ -248,12 +261,17 @@ def encode_batch(batch: NotificationBatch, maximum: int) -> bytes:
248
261
 
249
262
  def restore_batch(payload: bytes, maximum: int) -> NotificationBatch:
250
263
  record = load(payload, maximum)
264
+ if type(record) is dict and "messages_backend" not in record:
265
+ # Existing format-3 deliveries could only contain legacy summaries.
266
+ record["messages_backend"] = MessagingBackend.LEGACY.value
251
267
  if (
252
268
  type(record) is not dict
253
269
  or set(record) != {f.name for f in fields(NotificationBatch)}
254
270
  or type(record["items"]) is not list
255
271
  ):
256
272
  raise LibrusError(ErrorKind.PARSE)
273
+ if len(record["items"]) > 4096:
274
+ raise LibrusError(ErrorKind.LIMIT)
257
275
  items = []
258
276
  for entry in record["items"]:
259
277
  if (
@@ -264,6 +282,8 @@ def restore_batch(payload: bytes, maximum: int) -> NotificationBatch:
264
282
  ):
265
283
  raise LibrusError(ErrorKind.PARSE)
266
284
  item = entry["item"]
285
+ if type(item) is dict and "provenance" not in item:
286
+ item["provenance"] = NotificationProvenance.OBSERVED.value
267
287
  if type(item) is not dict or set(item) != {
268
288
  f.name for f in fields(NotificationItem)
269
289
  }:
@@ -282,10 +302,26 @@ def restore_batch(payload: bytes, maximum: int) -> NotificationBatch:
282
302
  value,
283
303
  parsed.identity,
284
304
  parsed.observation,
305
+ parsed.provenance,
285
306
  )
286
307
  )
287
308
  record["items"] = []
288
309
  batch = typed(NotificationBatch, record, maximum)
310
+ _validate_batch_items(batch, items)
311
+ return NotificationBatch(
312
+ batch.receipt,
313
+ batch.context,
314
+ batch.first_run,
315
+ batch.categories,
316
+ tuple(items),
317
+ batch.has_more_schedule,
318
+ batch.messages_backend,
319
+ )
320
+
321
+
322
+ def _validate_batch_items(
323
+ batch: NotificationBatch, items: list[NotificationItem]
324
+ ) -> None:
289
325
  if (
290
326
  HEX.fullmatch(batch.receipt) is None
291
327
  or HEX.fullmatch(batch.context.identifier) is None
@@ -293,18 +329,68 @@ def restore_batch(payload: bytes, maximum: int) -> NotificationBatch:
293
329
  or len(set(batch.categories)) != len(batch.categories)
294
330
  ):
295
331
  raise LibrusError(ErrorKind.PARSE)
332
+ for item in items:
333
+ if item.category not in batch.categories:
334
+ raise LibrusError(ErrorKind.PARSE)
335
+ if item.provenance is NotificationProvenance.IMPORTED_HISTORY:
336
+ validate_historical_item(item)
337
+ if (
338
+ batch.categories != (NotificationCategory.AGENDA,)
339
+ or batch.first_run
340
+ or batch.has_more_schedule
341
+ ):
342
+ raise LibrusError(ErrorKind.PARSE)
343
+ elif (
344
+ item.identity is None
345
+ or item.observation is None
346
+ or item.observation.account != batch.context.alias
347
+ or item.identity.observation.account != batch.context.alias
348
+ ):
349
+ raise LibrusError(ErrorKind.PARSE)
350
+ if isinstance(item.value, (MessageSummary, ModernMessageSummary)):
351
+ expected = (
352
+ MessagingBackend.MODERN
353
+ if isinstance(item.value, ModernMessageSummary)
354
+ else MessagingBackend.LEGACY
355
+ )
356
+ if (
357
+ batch.messages_backend is not expected
358
+ or item.value.reference.account != batch.context.alias
359
+ ):
360
+ raise LibrusError(ErrorKind.PARSE)
296
361
  if any(
297
- item.category not in batch.categories
298
- or item.observation.account != batch.context.alias
299
- or item.identity.observation.account != batch.context.alias
300
- for item in items
301
- ):
362
+ item.provenance is NotificationProvenance.IMPORTED_HISTORY for item in items
363
+ ) and not is_historical_batch(batch, items):
302
364
  raise LibrusError(ErrorKind.PARSE)
303
- return NotificationBatch(
304
- batch.receipt,
305
- batch.context,
306
- batch.first_run,
307
- batch.categories,
308
- tuple(items),
309
- batch.has_more_schedule,
365
+
366
+
367
+ def is_historical_batch(
368
+ batch: NotificationBatch, items: list[NotificationItem] | None = None
369
+ ) -> bool:
370
+ values = batch.items if items is None else items
371
+ return bool(values) and all(
372
+ item.provenance is NotificationProvenance.IMPORTED_HISTORY for item in values
310
373
  )
374
+
375
+
376
+ def validate_historical_item(item: NotificationItem) -> None:
377
+ if (
378
+ item.category is not NotificationCategory.AGENDA
379
+ or type(item.value) is not RecentScheduleEvent
380
+ or item.identity is not None
381
+ or item.observation is not None
382
+ or item.provenance is not NotificationProvenance.IMPORTED_HISTORY
383
+ ):
384
+ raise LibrusError(ErrorKind.PARSE)
385
+ value = item.value
386
+ for text, minimum, maximum in (
387
+ (value.date_added, 1, SCHOOL_MAX_FIELD_LENGTH),
388
+ (value.type, 1, SCHOOL_MAX_FIELD_LENGTH),
389
+ (value.data, 0, SCHOOL_MAX_CONTENT_LENGTH),
390
+ ):
391
+ if type(text) is not str or not minimum <= len(text) <= maximum:
392
+ raise LibrusError(ErrorKind.PARSE)
393
+ try:
394
+ text.encode("utf-8")
395
+ except UnicodeError:
396
+ raise LibrusError(ErrorKind.PARSE) from None
@@ -8,10 +8,11 @@ import os
8
8
  import secrets
9
9
  import sqlite3
10
10
  import stat
11
+ import sys
11
12
  from collections.abc import Callable, Coroutine, Iterator
12
13
  from contextlib import contextmanager
13
14
  from pathlib import Path
14
- from typing import Any, Self
15
+ from typing import TYPE_CHECKING, Any, Self
15
16
 
16
17
  from pydantic import Field
17
18
 
@@ -19,10 +20,16 @@ from librus_python_api.config import _ValidatedConfig
19
20
  from librus_python_api.exceptions import ErrorKind, LibrusError
20
21
  from librus_python_api.lifecycle import join_owned
21
22
 
23
+ if TYPE_CHECKING:
24
+ from librus_python_api._windows_filesystem import WindowsDirectory
25
+
22
26
 
23
27
  class _StorageLimits(_ValidatedConfig):
24
28
  operations: int = Field(default=8, ge=1, le=64)
25
- busy_timeout_seconds: float = Field(default=0.1, gt=0, le=5, allow_inf_nan=False)
29
+ # Several local processes (one per agent host session) commonly share one
30
+ # store. Each transaction is short, but FULL synchronous commits can exceed
31
+ # 100 ms on slow disks, so ordinary operations wait up to one second.
32
+ busy_timeout_seconds: float = Field(default=1.0, gt=0, le=5, allow_inf_nan=False)
26
33
  # Final saves follow upstream side effects (a dispatched send or a consumed
27
34
  # read-once page), so they wait longer for other contexts' write locks.
28
35
  final_busy_timeout_seconds: float = Field(
@@ -60,6 +67,7 @@ class _SQLiteStore:
60
67
  self._close_task: asyncio.Task[None] | None = None
61
68
  self._file_identity: tuple[int, int] | None = None
62
69
  self._context_salt: bytes | None = None
70
+ self._windows_directory: WindowsDirectory | None = None
63
71
 
64
72
  def context_identifier(self, context: str) -> str:
65
73
  """Store-local context pseudonym; requires an explicitly opened store."""
@@ -87,7 +95,7 @@ class _SQLiteStore:
87
95
  await self.aclose()
88
96
 
89
97
  async def open(self) -> None:
90
- if os.name != "posix":
98
+ if os.name != "posix" and sys.platform != "win32":
91
99
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
92
100
  if self._opened or self._opening or self._closing or self._closed:
93
101
  raise LibrusError(ErrorKind.INVALID_INPUT)
@@ -97,6 +105,9 @@ class _SQLiteStore:
97
105
  self._opened = True
98
106
  finally:
99
107
  self._opening = False
108
+ if not self._opened and self._windows_directory is not None:
109
+ self._windows_directory.close()
110
+ self._windows_directory = None
100
111
 
101
112
  async def aclose(self) -> None:
102
113
  if (
@@ -123,6 +134,13 @@ class _SQLiteStore:
123
134
  task.cancel()
124
135
  for task in tasks:
125
136
  await join_owned(task)
137
+ if self._windows_directory is not None:
138
+ try:
139
+ self._windows_directory.close()
140
+ except OSError:
141
+ raise LibrusError(ErrorKind.STORAGE) from None
142
+ finally:
143
+ self._windows_directory = None
126
144
  self._closed = True
127
145
  self._opened = False
128
146
 
@@ -207,6 +225,14 @@ class _SQLiteStore:
207
225
  def _check_directory(self) -> None:
208
226
  # A private immediate parent is the trust boundary. Malicious same-user
209
227
  # filesystem writers and unsuitable network filesystems are not isolated.
228
+ if sys.platform == "win32":
229
+ from librus_python_api._windows_filesystem import WindowsDirectory
230
+
231
+ if self._windows_directory is None:
232
+ self._windows_directory = WindowsDirectory(self._directory, create=True)
233
+ else:
234
+ self._windows_directory.validate()
235
+ return
210
236
  if self._directory.is_symlink():
211
237
  raise LibrusError(ErrorKind.STORAGE)
212
238
  self._directory.mkdir(mode=0o700, exist_ok=True)
@@ -219,6 +245,14 @@ class _SQLiteStore:
219
245
  raise LibrusError(ErrorKind.STORAGE)
220
246
 
221
247
  def _check_file(self, *, create: bool = False) -> None:
248
+ if self._windows_directory is not None:
249
+ identity = self._windows_directory.check_file(
250
+ self._filename, self._database_bytes, create=create
251
+ )
252
+ if self._file_identity is not None and identity != self._file_identity:
253
+ raise LibrusError(ErrorKind.STORAGE)
254
+ self._file_identity = identity
255
+ return
222
256
  if self._path.is_symlink():
223
257
  raise LibrusError(ErrorKind.STORAGE)
224
258
  flags = os.O_RDWR | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
@@ -246,6 +280,11 @@ class _SQLiteStore:
246
280
 
247
281
  def _check_sidecars(self) -> None:
248
282
  for suffix in ("-journal", "-wal", "-shm"):
283
+ if self._windows_directory is not None:
284
+ self._windows_directory.check_sidecar(
285
+ self._filename + suffix, self._database_bytes + 1024 * 1024
286
+ )
287
+ continue
249
288
  path = self._path.with_name(self._path.name + suffix)
250
289
  try:
251
290
  status = path.lstat()