librus-python-api 1.0.3__py3-none-any.whl → 1.3.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.
@@ -14,11 +14,17 @@ from librus_python_api.config import (
14
14
  MESSAGE_MAX_CURSOR_IDS,
15
15
  MESSAGE_MAX_FIELD_LENGTH,
16
16
  MESSAGE_MAX_TOTAL_TEXT_LENGTH,
17
+ MODERN_MAX_LABEL,
18
+ MODERN_MAX_RECIPIENTS,
19
+ MODERN_MAX_UNREAD_COUNT,
20
+ MODERN_UNREAD_COUNT_FIELDS,
17
21
  modern_mailbox_query,
18
22
  )
19
23
  from librus_python_api.exceptions import ErrorKind, LibrusError
20
24
  from librus_python_api.models import (
21
25
  MessageFolder,
26
+ ModernCorrespondent,
27
+ ModernCorrespondentReference,
22
28
  ModernMessageAttachment,
23
29
  ModernMessageAttachmentReference,
24
30
  ModernMessageRecipientReceipt,
@@ -26,6 +32,7 @@ from librus_python_api.models import (
26
32
  ModernMessagesCursor,
27
33
  ModernMessagesPage,
28
34
  ModernMessageSummary,
35
+ ModernUnreadFolders,
29
36
  )
30
37
  from librus_python_api.modern_body import render_body
31
38
  from librus_python_api.parsers import decode_json
@@ -48,6 +55,7 @@ def validate_reference(reference: ModernMessageReference, account: str) -> None:
48
55
  not isinstance(reference, ModernMessageReference)
49
56
  or not isinstance(reference.folder, MessageFolder)
50
57
  or reference.account != account
58
+ or type(reference.archived) is not bool
51
59
  or type(reference.identifier) is not str
52
60
  or re.fullmatch(r"[0-9]{1,64}", reference.identifier) is None
53
61
  ):
@@ -75,7 +83,9 @@ def _date(value: Any) -> datetime:
75
83
  raise LibrusError(ErrorKind.PARSE)
76
84
 
77
85
 
78
- def _summary(data: Any, folder: MessageFolder, account: str) -> ModernMessageSummary:
86
+ def _summary(
87
+ data: Any, folder: MessageFolder, account: str, archived: bool
88
+ ) -> ModernMessageSummary:
79
89
  if not isinstance(data, dict):
80
90
  raise LibrusError(ErrorKind.PARSE)
81
91
  correspondent = (
@@ -98,7 +108,9 @@ def _summary(data: Any, folder: MessageFolder, account: str) -> ModernMessageSum
98
108
  read = data.get("readDate")
99
109
  read_at = None if read in (None, "") else _date(read)
100
110
  return ModernMessageSummary(
101
- ModernMessageReference(folder, identifier(data.get("messageId")), account),
111
+ ModernMessageReference(
112
+ folder, identifier(data.get("messageId")), account, archived
113
+ ),
102
114
  name,
103
115
  subject,
104
116
  _date(stamp),
@@ -110,13 +122,23 @@ def _summary(data: Any, folder: MessageFolder, account: str) -> ModernMessageSum
110
122
 
111
123
 
112
124
  def parse_page(
113
- body: bytes, folder: MessageFolder, page: int, page_size: int, account: str
114
- ) -> tuple[tuple[ModernMessageSummary, ...], int, str]:
125
+ body: bytes,
126
+ folder: MessageFolder,
127
+ page: int,
128
+ page_size: int,
129
+ account: str,
130
+ archived: bool = False,
131
+ ) -> tuple[tuple[ModernMessageSummary, ...], int, str, bool | None]:
115
132
  modern_mailbox_query(folder, page, page_size)
116
133
  data = decode_json(body)
117
134
  if not isinstance(data, dict) or not {"data", "total"} <= set(data):
118
135
  raise LibrusError(ErrorKind.PARSE)
119
- if data.get("archivingInProgress", False) is not False:
136
+ in_progress = data.get("archivingInProgress", False)
137
+ if type(in_progress) is not bool:
138
+ raise LibrusError(ErrorKind.PARSE)
139
+ # Live archive pages report true while listing normally; it is a status
140
+ # flag there. The current mailbox has never shown it.
141
+ if in_progress and not archived:
120
142
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
121
143
  entries, total = data["data"], data["total"]
122
144
  if (
@@ -130,7 +152,7 @@ def parse_page(
130
152
  expected = min(page_size, max(0, total - (page - 1) * page_size))
131
153
  if len(entries) != expected:
132
154
  raise LibrusError(ErrorKind.PARSE)
133
- items = tuple(_summary(item, folder, account) for item in entries)
155
+ items = tuple(_summary(item, folder, account, archived) for item in entries)
134
156
  if len({item.reference.identifier for item in items}) != len(items):
135
157
  raise LibrusError(ErrorKind.PARSE)
136
158
  if (
@@ -155,7 +177,7 @@ def parse_page(
155
177
  )
156
178
  ).encode()
157
179
  ).hexdigest()
158
- return items, total, fingerprint
180
+ return items, total, fingerprint, in_progress if archived else None
159
181
 
160
182
 
161
183
  @dataclass(frozen=True, slots=True, repr=False)
@@ -280,6 +302,7 @@ def parse_content(body: bytes, reference: ModernMessageReference) -> ParsedConte
280
302
  content | {"isAnyFileAttached": has_attachment},
281
303
  reference.folder,
282
304
  reference.account,
305
+ reference.archived,
283
306
  )
284
307
  if summary.reference != reference:
285
308
  raise LibrusError(ErrorKind.PARSE)
@@ -326,8 +349,15 @@ def validate_selection(
326
349
  max_pages: int,
327
350
  limit: int,
328
351
  account: str,
352
+ archived: bool = False,
353
+ correspondent: str | None = None,
354
+ unread_only: bool = False,
329
355
  ) -> None:
330
- modern_mailbox_query(folder, 1, page_size)
356
+ modern_mailbox_query(
357
+ folder, 1, page_size, correspondent=correspondent, unread_only=unread_only
358
+ )
359
+ if type(archived) is not bool:
360
+ raise LibrusError(ErrorKind.INVALID_INPUT)
331
361
  if type(max_pages) is not int or not 1 <= max_pages <= MESSAGE_MAX_BATCH_PAGES:
332
362
  raise LibrusError(ErrorKind.INVALID_INPUT)
333
363
  if type(limit) is not int or not 1 <= limit <= MESSAGE_MAX_BATCH_ITEMS:
@@ -340,6 +370,9 @@ def validate_selection(
340
370
  if (
341
371
  cursor.account != account
342
372
  or cursor.folder is not folder
373
+ or cursor.archived is not archived
374
+ or cursor.correspondent != correspondent
375
+ or cursor.unread_only is not unread_only
343
376
  or cursor.page_size != page_size
344
377
  or type(cursor.offset) is not int
345
378
  or not 0 <= cursor.offset < page_size
@@ -367,6 +400,9 @@ async def collect(
367
400
  page_size: int,
368
401
  max_pages: int,
369
402
  limit: int,
403
+ archived: bool = False,
404
+ correspondent: str | None = None,
405
+ unread_only: bool = False,
370
406
  ) -> tuple[
371
407
  tuple[ModernMessageSummary, ...],
372
408
  int,
@@ -374,7 +410,17 @@ async def collect(
374
410
  ModernMessagesCursor | None,
375
411
  Literal["item_limit", "page_limit"] | None,
376
412
  ]:
377
- validate_selection(folder, cursor, page_size, max_pages, limit, account)
413
+ validate_selection(
414
+ folder,
415
+ cursor,
416
+ page_size,
417
+ max_pages,
418
+ limit,
419
+ account,
420
+ archived,
421
+ correspondent,
422
+ unread_only,
423
+ )
378
424
  page = cursor.page if cursor else 1
379
425
  offset = cursor.offset if cursor else 0
380
426
  seen = list(cursor.seen_ids) if cursor else []
@@ -427,6 +473,9 @@ async def collect(
427
473
  total,
428
474
  result.fingerprint,
429
475
  tuple(seen),
476
+ archived,
477
+ correspondent,
478
+ unread_only,
430
479
  ),
431
480
  "item_limit",
432
481
  )
@@ -452,8 +501,87 @@ async def collect(
452
501
  total,
453
502
  result.fingerprint,
454
503
  tuple(seen),
504
+ archived,
505
+ correspondent,
506
+ unread_only,
455
507
  ),
456
508
  "item_limit" if len(items) >= limit else "page_limit",
457
509
  )
458
510
  page, offset = page + 1, 0
459
511
  raise AssertionError("Bounded collector must terminate")
512
+
513
+
514
+ def _unread_folders(data: dict[str, Any], prefix: str) -> ModernUnreadFolders:
515
+ values = []
516
+ for name in MODERN_UNREAD_COUNT_FIELDS:
517
+ key = prefix + name[0].upper() + name[1:] if prefix else name
518
+ value = data.get(key)
519
+ if type(value) is not int or value < 0:
520
+ raise LibrusError(ErrorKind.PARSE)
521
+ if value > MODERN_MAX_UNREAD_COUNT:
522
+ raise LibrusError(ErrorKind.LIMIT)
523
+ values.append(value)
524
+ return ModernUnreadFolders(*values)
525
+
526
+
527
+ def parse_unread_counts(
528
+ body: bytes,
529
+ ) -> tuple[ModernUnreadFolders, ModernUnreadFolders]:
530
+ """Current and archive counters; unknown extra counters stay inert."""
531
+ data = decode_json(body)
532
+ counts = data.get("data") if isinstance(data, dict) else None
533
+ if not isinstance(counts, dict):
534
+ raise LibrusError(ErrorKind.PARSE)
535
+ return _unread_folders(counts, ""), _unread_folders(counts, "archive")
536
+
537
+
538
+ def validate_correspondent(
539
+ reference: ModernCorrespondentReference | None, folder: MessageFolder, account: str
540
+ ) -> str | None:
541
+ """The filter value for a correspondent listed for this folder and login."""
542
+ if reference is None:
543
+ return None
544
+ if (
545
+ not isinstance(reference, ModernCorrespondentReference)
546
+ or reference.folder is not folder
547
+ or reference.account != account
548
+ or type(reference.identifier) is not str
549
+ or re.fullmatch(r"[0-9]{1,64}", reference.identifier) is None
550
+ ):
551
+ raise LibrusError(ErrorKind.INVALID_INPUT)
552
+ return reference.identifier
553
+
554
+
555
+ def _name(value: Any) -> str:
556
+ if type(value) is not str or len(value) > MODERN_MAX_LABEL or "\x00" in value:
557
+ raise LibrusError(ErrorKind.PARSE)
558
+ return value
559
+
560
+
561
+ def parse_correspondents(
562
+ body: bytes, folder: MessageFolder, account: str
563
+ ) -> tuple[ModernCorrespondent, ...]:
564
+ """People the folder's messages came from (received) or went to (sent)."""
565
+ role = "sender" if folder is MessageFolder.RECEIVED else "receiver"
566
+ data = decode_json(body)
567
+ entries = data.get("data") if isinstance(data, dict) else None
568
+ if not isinstance(entries, list):
569
+ raise LibrusError(ErrorKind.PARSE)
570
+ if len(entries) > MODERN_MAX_RECIPIENTS:
571
+ raise LibrusError(ErrorKind.LIMIT)
572
+ items = []
573
+ for entry in entries:
574
+ if not isinstance(entry, dict):
575
+ raise LibrusError(ErrorKind.PARSE)
576
+ first = _name(entry.get(f"{role}FirstName"))
577
+ last = _name(entry.get(f"{role}LastName"))
578
+ # Institutional senders can carry a single name part; never both empty.
579
+ if not (first.strip() or last.strip()):
580
+ raise LibrusError(ErrorKind.PARSE)
581
+ reference = ModernCorrespondentReference(
582
+ folder, identifier(entry.get(f"{role}Id")), account
583
+ )
584
+ items.append(ModernCorrespondent(reference, first, last))
585
+ if len({item.reference.identifier for item in items}) != len(items):
586
+ raise LibrusError(ErrorKind.PARSE)
587
+ return tuple(items)
@@ -23,6 +23,7 @@ from librus_python_api.models import (
23
23
  ModernRecipientReference,
24
24
  ModernRecipientType,
25
25
  ModernRecipientTypeReference,
26
+ ModernTeacherSubject,
26
27
  SendStatus,
27
28
  )
28
29
  from librus_python_api.parsers import decode_json
@@ -274,3 +275,25 @@ def parse_modern_send_response(body: bytes, status: int) -> SendStatus:
274
275
  ):
275
276
  return SendStatus.REJECTED
276
277
  raise LibrusError(ErrorKind.UNKNOWN_DELIVERY)
278
+
279
+
280
+ def parse_teacher_subjects(body: bytes) -> tuple[ModernTeacherSubject, ...]:
281
+ """Teacher account IDs paired with the subjects they teach the student."""
282
+ entries = _object(decode_json(body)).get("data")
283
+ if not isinstance(entries, list):
284
+ raise LibrusError(ErrorKind.PARSE)
285
+ if len(entries) > MODERN_MAX_RECIPIENTS:
286
+ raise LibrusError(ErrorKind.LIMIT)
287
+ items = []
288
+ for entry in entries:
289
+ value = _object(entry)
290
+ raw = value.get("teacherIdentifier")
291
+ if type(raw) is int and 0 < raw < 10**64:
292
+ raw = str(raw)
293
+ item = ModernTeacherSubject(_identifier(raw), _label(value.get("subject")))
294
+ # Live lists can repeat an identical pair; the copy adds nothing.
295
+ if item not in items:
296
+ items.append(item)
297
+ if sum(len(item.subject) for item in items) > MODERN_MAX_TOTAL_TEXT:
298
+ raise LibrusError(ErrorKind.LIMIT)
299
+ return tuple(items)