librus-python-api 1.0.0rc1__tar.gz → 1.0.2__tar.gz
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-1.0.0rc1 → librus_python_api-1.0.2}/API.md +154 -13
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/CHANGELOG.md +65 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/PKG-INFO +25 -16
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/README.md +21 -15
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/pyproject.toml +4 -2
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/__init__.py +1 -1
- librus_python_api-1.0.2/src/librus_python_api/_notification_bootstrap.py +124 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/_notification_codec.py +99 -13
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/_storage.py +42 -3
- librus_python_api-1.0.2/src/librus_python_api/_windows_filesystem.py +390 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/attendance_frequency.py +65 -2
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/config.py +25 -5
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/files.py +108 -17
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/models.py +20 -0
- librus_python_api-1.0.2/src/librus_python_api/notification_models.py +145 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/notification_persistence.py +230 -31
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/notification_workflow.py +41 -7
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/persistence.py +14 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/service.py +53 -3
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/uv.lock +40 -0
- librus_python_api-1.0.0rc1/src/librus_python_api/notification_models.py +0 -67
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/.gitignore +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/LICENSE +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/SECURITY.md +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/announcements.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/attachment_routes.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/attachments.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/attendance.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/budget.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/checkpoint.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/completed_lessons.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/detail_fields.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/diagnostics.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/exceptions.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/grade_parsers.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/grade_records.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/homework_range.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/lifecycle.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/markup.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/message_content.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/messages.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/modern_body.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/modern_mailbox.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/modern_messages.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/notifications.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/parsers.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/parsing.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/py.typed +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/recipients.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/scheduler.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/school_reads.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/sending.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/timetable.py +0 -0
- {librus_python_api-1.0.0rc1 → librus_python_api-1.0.2}/src/librus_python_api/transport.py +0 -0
|
@@ -5,6 +5,36 @@ Core public values are exported from `librus_python_api`; exceptions live in
|
|
|
5
5
|
`librus_python_api.persistence`. Results are frozen dataclasses. Their reprs omit
|
|
6
6
|
personal fields. Serializing them for MCP or anything else is the consumer's job.
|
|
7
7
|
|
|
8
|
+
## Compatibility policy
|
|
9
|
+
|
|
10
|
+
Version 1.0 freezes the documented public library contracts: root exports and
|
|
11
|
+
explicit optional modules described here, typed account/source ownership, bounded
|
|
12
|
+
traffic/resources, explicit effect consent and fail-closed error semantics.
|
|
13
|
+
Private names, parser internals, SQLite SQL layout and encoded archive internals
|
|
14
|
+
are not consumer integration APIs. Use the supported typed persistence methods.
|
|
15
|
+
|
|
16
|
+
Within 1.x, patch releases repair documented behavior and security issues; minor
|
|
17
|
+
releases add supported capabilities. Removal/renaming of public methods or
|
|
18
|
+
required fields, incompatible argument/return types, or changes to established
|
|
19
|
+
canonical IDs require a major release. Dataclass fields may be added with defaults;
|
|
20
|
+
construct by keyword and serialize deliberately, not by assuming a fixed repr.
|
|
21
|
+
Undocumented upstream layouts are not compatibility promises: they may fail with
|
|
22
|
+
typed PARSE/UNSUPPORTED_CAPABILITY rather than produce guessed partial output.
|
|
23
|
+
Security fixes can tighten validation of previously accepted invalid/unsafe input.
|
|
24
|
+
|
|
25
|
+
Persisted supported records are validated without silent reset or reinterpretation.
|
|
26
|
+
An incompatible future format requires explicit migration and preserved originals.
|
|
27
|
+
Upgrades can introduce records older versions cannot read; keep original files and
|
|
28
|
+
archives for consumer-owned rollback, never clear UNKNOWN sends or acknowledge
|
|
29
|
+
notifications merely to make a downgrade work. The stable application context key
|
|
30
|
+
remains required. The neutral bootstrap does not automatically migrate old consumer
|
|
31
|
+
files or reconstruct missing identity/observation provenance.
|
|
32
|
+
|
|
33
|
+
Library stability is not a Librus availability guarantee or completed MCP migration.
|
|
34
|
+
The documented default-off features, unobserved layouts and pending live evidence
|
|
35
|
+
stay limited as stated. Consumers qualify their own DTO/stdio contracts, mapped
|
|
36
|
+
state migration/rollback and exact installed dependency before production cutover.
|
|
37
|
+
|
|
8
38
|
## Optional local attachment files
|
|
9
39
|
|
|
10
40
|
```python
|
|
@@ -25,6 +55,14 @@ content type. It neither selects the destination nor opens message content.
|
|
|
25
55
|
See [the file contract](contracts/attachment-files.md) for filesystem requirements,
|
|
26
56
|
bounded naming and cancellation/commit semantics.
|
|
27
57
|
|
|
58
|
+
`await prepare_attachment_directory(directory: Path)` explicitly creates or
|
|
59
|
+
validates an absolute caller-selected private directory whose parent exists.
|
|
60
|
+
It creates mode 0700 on POSIX or a protected inheritable private ACL on local
|
|
61
|
+
Windows NTFS. Unsafe existing directories reject, never have their permissions
|
|
62
|
+
silently changed. It performs no HTTP and joins disk work on cancellation, which
|
|
63
|
+
may leave a complete empty directory. `publish_attachment` still requires an
|
|
64
|
+
existing directory; it never creates a destination implicitly.
|
|
65
|
+
|
|
28
66
|
## Service and accounts
|
|
29
67
|
|
|
30
68
|
```python
|
|
@@ -74,7 +112,7 @@ refunded. Exhaustion raises `LimitError`; deadlines raise `OperationTimeoutError
|
|
|
74
112
|
|
|
75
113
|
## Configuration
|
|
76
114
|
|
|
77
|
-
- `SchedulerLimits`:
|
|
115
|
+
- `SchedulerLimits`: 10 requests/second, burst 20, 4 active requests, 1 per
|
|
78
116
|
account, 32 queued (8 per account), 16 accounts, 32 concurrent operations
|
|
79
117
|
(8 per account). These are engineering defaults, not a published Librus quota.
|
|
80
118
|
- `TransportLimits`: 30 s request and 10 s connect timeouts, 4 MiB bodies, 10
|
|
@@ -469,8 +507,10 @@ context identifiers are store-local HMAC-SHA256 pseudonyms, not the public
|
|
|
469
507
|
`client.context.identifier` application-keyed pseudonym. `store.context_identifier(client.context.identifier)`
|
|
470
508
|
returns that pseudonym only on an open store. Core clients remain storage-independent.
|
|
471
509
|
Version-1/2 stores reject without modification; no automatic migration/reset.
|
|
472
|
-
Opening either store requires POSIX
|
|
473
|
-
|
|
510
|
+
Opening either store requires local POSIX filesystem safeguards or fixed local
|
|
511
|
+
Windows NTFS with private protected inheritable ACLs. Network/UNC/device paths,
|
|
512
|
+
non-NTFS volumes and reparse-point paths are unsupported or rejected before unsafe
|
|
513
|
+
work. See [the Windows boundary](contracts/persistence.md#windows-disk-boundary-25).
|
|
474
514
|
|
|
475
515
|
`await store.prune_send_history(context=client.context, identifiers=(...),
|
|
476
516
|
allow_accepted=False)` returns the number of deleted rows. Use identifiers from
|
|
@@ -525,7 +565,7 @@ creates state, reads old MCP files or starts background recovery.
|
|
|
525
565
|
tokens and neither clears uncertainty nor authorizes another send.
|
|
526
566
|
- SQLite FULL synchronous transactions serialize cross-process claims. Unknown
|
|
527
567
|
schema, extra triggers, corrupt data, replaced files, symlinks, non-regular
|
|
528
|
-
files and unsafe POSIX permissions fail closed, never reset
|
|
568
|
+
files and unsafe POSIX permissions or Windows ACLs fail closed, never reset state.
|
|
529
569
|
No automatic retry, reconciliation, deletion of consumed history or polling.
|
|
530
570
|
- Defaults: 8 admitted storage workers and 8 send workflows, 256 total records,
|
|
531
571
|
32 unused previews, 300-second expiry and 0.1-second SQLite busy timeout.
|
|
@@ -565,7 +605,10 @@ notified again; no automatic school-year/age expiry is performed.
|
|
|
565
605
|
|
|
566
606
|
Explicitly import `NotificationStore`, `NotificationLimits`, `NotificationWorkflow`,
|
|
567
607
|
`NotificationBatch`, `NotificationItem`, `NotificationState`, `NotificationSeen`,
|
|
568
|
-
`NotificationArchive`
|
|
608
|
+
`NotificationArchive`, `NotificationProvenance`, `NotificationBaselineMapping`,
|
|
609
|
+
`NotificationBootstrap`, `NotificationBootstrapResult`, `NotificationRecoveryStatus`,
|
|
610
|
+
`NotificationPendingDelivery`, `NotificationRawCheckpointStatus` and
|
|
611
|
+
`canonical_notification_id` from
|
|
569
612
|
`librus_python_api.persistence`. Construction is inert; core clients remain
|
|
570
613
|
independent of this optional layer.
|
|
571
614
|
|
|
@@ -585,14 +628,19 @@ async with NotificationStore(Path("/absolute/private/notification-state")) as st
|
|
|
585
628
|
- `NotificationStore(directory: Path, *, limits=None)` opens the private separate
|
|
586
629
|
`notifications.sqlite3` via `async with` or explicit `open()`/`aclose()`.
|
|
587
630
|
Existing send databases remain unchanged. Select a trusted local filesystem
|
|
588
|
-
with SQLite/fsync
|
|
631
|
+
with SQLite/fsync and POSIX flock or Windows NTFS/Win32 lock support; unsupported
|
|
632
|
+
platforms fail explicitly on
|
|
589
633
|
context operations. One store belongs to one event loop. Shutdown/cancellation
|
|
590
634
|
joins owned work. Same-context competition fails with LIMIT, without HTTP.
|
|
591
|
-
- `NotificationWorkflow(client, store
|
|
635
|
+
- `NotificationWorkflow(client, store, *, messages_backend=MessagingBackend.LEGACY)`
|
|
636
|
+
explicitly selects the message source. Use `MessagingBackend.MODERN` for modern
|
|
637
|
+
notifications; it does not fall back to the other mailbox on failure.
|
|
638
|
+
`.poll(*, categories, allow_consume_events=False,
|
|
592
639
|
homework_window=None, checkpoint_timeout_seconds=5.0, budget=None)` accepts a
|
|
593
640
|
nonempty tuple of distinct native categories. Ordinary reads precede schedule
|
|
594
641
|
consumption. Homework defaults to today minus seven days through today in
|
|
595
|
-
Europe/Warsaw. Grades/attendance use LAST_LOGIN
|
|
642
|
+
Europe/Warsaw. Grades/attendance use LAST_LOGIN. Messages use one received summary
|
|
643
|
+
page (legacy page zero or modern page one, at most ten modern summaries),
|
|
596
644
|
announcements use the ordinary collection. No menu counts, message content,
|
|
597
645
|
detail calls, modern fallback or arbitrary historical catch-up is performed.
|
|
598
646
|
- New `AGENDA` consumption requires explicit `allow_consume_events=True` each
|
|
@@ -600,12 +648,18 @@ async with NotificationStore(Path("/absolute/private/notification-state")) as st
|
|
|
600
648
|
Existing raw checkpoints replay locally before another consume. Malformed raw
|
|
601
649
|
data remains stored and blocks another consume, even with fresh consent.
|
|
602
650
|
- `NotificationBatch(receipt, context, first_run, categories, items,
|
|
603
|
-
has_more_schedule)` is a durably staged
|
|
604
|
-
|
|
651
|
+
has_more_schedule, messages_backend=MessagingBackend.LEGACY)` is a durably staged
|
|
652
|
+
batch. Modern message items contain `ModernMessageSummary`, not message bodies.
|
|
653
|
+
Each `NotificationItem` carries
|
|
654
|
+
category, canonical identifier, native typed value and provenance. OBSERVED
|
|
655
|
+
items have identity and observation; imported historical agenda items have
|
|
656
|
+
neither. Check `item.provenance` before using authentication metadata.
|
|
605
657
|
First run diffs against empty IDs and ends only at acknowledgement. Requested
|
|
606
658
|
categories alone update seen state. Until acknowledgement, polling the exact
|
|
607
659
|
same category tuple returns that batch without authentication/HTTP. A different
|
|
608
|
-
tuple fails explicitly, not silently discarding the prior
|
|
660
|
+
tuple or message backend fails explicitly, not silently discarding the prior
|
|
661
|
+
delivery. Backend changes after acknowledgement preserve both seen namespaces;
|
|
662
|
+
the same numeric ID in two backends is not the same notification.
|
|
609
663
|
- `await workflow.acknowledge(receipt)` (or
|
|
610
664
|
`await store.acknowledge(receipt, context=client.context)`) atomically saves seen
|
|
611
665
|
IDs, advances the schedule cursor and cleans completed raw/delivery rows.
|
|
@@ -624,7 +678,34 @@ async with NotificationStore(Path("/absolute/private/notification-state")) as st
|
|
|
624
678
|
at 64 MiB and global staged delivery/candidate state at 16 MiB. Bounds fail closed
|
|
625
679
|
without silently evicting history. A retained raw envelope can be recovered with
|
|
626
680
|
explicit supported larger limits; oversized single events are never skipped.
|
|
627
|
-
- `await store.
|
|
681
|
+
- `await store.recovery_status(context=client.context)` returns a compact
|
|
682
|
+
`NotificationRecoveryStatus(context, initialized, last_acknowledged_receipt,
|
|
683
|
+
pending, raw, uncertain_consume)` snapshot without HTTP or registration of a
|
|
684
|
+
missing context. `pending` is `NotificationPendingDelivery(receipt, categories,
|
|
685
|
+
messages_backend, first_run, item_count, has_more_schedule)` or None. `raw` is
|
|
686
|
+
`NotificationRawCheckpointStatus(identifier, cursor, total, wire_bytes)` or None;
|
|
687
|
+
total can be unknown until parsing. Cursor is acknowledged progress only.
|
|
688
|
+
These are independent facts: ordinary pending delivery can coexist with retained
|
|
689
|
+
raw or uncertainty. `has_pending_work` includes all three, not just delivery.
|
|
690
|
+
A conservative marker can also belong to an in-flight poll; it is not proof
|
|
691
|
+
that the owner has finished or that another consume is safe. Resolution still
|
|
692
|
+
requires exclusive context ownership and explicit acceptance of possible loss.
|
|
693
|
+
Status exposes no event bodies or seen-ID lists and never advances recovery,
|
|
694
|
+
acknowledges a receipt, clears ambiguity, prunes or authenticates.
|
|
695
|
+
- `await store.pending_batch(context=client.context)` returns the original staged
|
|
696
|
+
`NotificationBatch` or None, without category/backend arguments or HTTP. It
|
|
697
|
+
performs no raw parsing/staging: None can coexist with raw or uncertain work;
|
|
698
|
+
inspect status before deciding no recovery is needed. Retrieval remains bounded
|
|
699
|
+
by the existing batch limits and repeats unchanged until acknowledgement.
|
|
700
|
+
Raw-only recovery still requires an explicit AGENDA poll to decode locally;
|
|
701
|
+
uncertainty still requires the separate loss-consenting resolution operation.
|
|
702
|
+
Unknown valid contexts return an empty status/None and do not consume capacity.
|
|
703
|
+
Invalid contexts, malformed persisted records and aliases inconsistent with
|
|
704
|
+
retained metadata reject; no foreign delivery is returned. Use the service-owned
|
|
705
|
+
account context, not fabricated values. Status and batch lookup each read an
|
|
706
|
+
atomic snapshot, but a competing acknowledgement between the two can make an
|
|
707
|
+
earlier status stale. Use the retrieved batch receipt, never reconstruct one.
|
|
708
|
+
- `await store.export_archive(context=...)` returns a version-3 neutral archive
|
|
628
709
|
containing exact context, seen IDs, receipt, raw bytes/progress, pending delivery
|
|
629
710
|
and uncertainty. `await store.import_archive(archive)` is empty-target-only and
|
|
630
711
|
validates the complete import transaction before commit. Unknown versions,
|
|
@@ -637,7 +718,12 @@ async with NotificationStore(Path("/absolute/private/notification-state")) as st
|
|
|
637
718
|
including possible pre-dispatch failure; it never expires or authorizes retry.
|
|
638
719
|
Clearing it accepts possible lost upstream events, not proof of no consumption.
|
|
639
720
|
|
|
640
|
-
`canonical_notification_id(category, value)`
|
|
721
|
+
`canonical_notification_id(category, value)` preserves version-1 legacy identities
|
|
722
|
+
and uses a separate version-2 modern-message domain including backend, folder and
|
|
723
|
+
message ID. Existing format-3 pending batches without `messages_backend` are read
|
|
724
|
+
as legacy. New pending batches are not guaranteed readable by older libraries;
|
|
725
|
+
keep original databases/archives for consumer-owned rollback and do not replace
|
|
726
|
+
them with new exports. No SQLite schema or existing seen-ID rewrite is performed.
|
|
641
727
|
Schedule hashes cover date_added/type/data; stable native IDs identify ordinary
|
|
642
728
|
records where available, otherwise complete visible typed content. Stable-ID
|
|
643
729
|
updates do not automatically re-notify. MCP `schedule`/legacy IDs need a separately
|
|
@@ -645,6 +731,61 @@ qualified explicit mapping to `agenda`/native IDs. See
|
|
|
645
731
|
[contracts/persistence.md](contracts/persistence.md) for compatibility and loss
|
|
646
732
|
windows. No live qualification or MCP migration is implied by offline recovery.
|
|
647
733
|
|
|
734
|
+
### Offline external baseline bootstrap
|
|
735
|
+
|
|
736
|
+
`await store.bootstrap(plan, *, context=client.context)` accepts a typed
|
|
737
|
+
`NotificationBootstrap(context, mappings, pending_events)`. It performs no HTTP.
|
|
738
|
+
The caller reads its own old files, establishes native canonical IDs and maps
|
|
739
|
+
each old baseline ID with `NotificationBaselineMapping(category,
|
|
740
|
+
source_identifier, native_identifier)`. Source IDs are opaque nonempty strings
|
|
741
|
+
up to 256 characters; native IDs are lowercase 64-character SHA256 values.
|
|
742
|
+
Use `canonical_notification_id` with independently established native records,
|
|
743
|
+
not a hash of an incompatible old identifier. Numeric message IDs require their
|
|
744
|
+
original backend and folder. Content-derived native IDs cannot be reconstructed
|
|
745
|
+
from a legacy opaque ID alone.
|
|
746
|
+
|
|
747
|
+
An unmappable ID must be submitted with `native_identifier=None`. The result has
|
|
748
|
+
`imported=False`, `unmapped` containing those exact mappings and `pending=None`;
|
|
749
|
+
no context, state or pending work is written. A malformed category, ID, mapping
|
|
750
|
+
collision or context mismatch instead raises INVALID_INPUT. Never discard the
|
|
751
|
+
unmapped report and silently poll a fresh baseline. Resolve mappings offline or
|
|
752
|
+
stop migration with the originals intact.
|
|
753
|
+
|
|
754
|
+
Successful import is empty-target-context only and non-overwriting. It initializes
|
|
755
|
+
the mapped baseline immediately, even when the explicitly supplied mapping tuple
|
|
756
|
+
is empty. Pending historical events are typed `RecentScheduleEvent` values with
|
|
757
|
+
the original three strings unchanged; no raw page, identity, observation timestamp
|
|
758
|
+
or session generation is invented. They form a single staged AGENDA batch with
|
|
759
|
+
`first_run=False`, `has_more_schedule=False` and items marked
|
|
760
|
+
`NotificationProvenance.IMPORTED_HISTORY` with `identity=None`, `observation=None`.
|
|
761
|
+
Observed native items remain marked OBSERVED and require both metadata values.
|
|
762
|
+
|
|
763
|
+
Mappings, seen IDs, pending count/text and encoded batch/state bytes are bounded
|
|
764
|
+
by `NotificationLimits` and the native field limits. All pending events must fit
|
|
765
|
+
one batch; oversized inputs fail with LIMIT, never partial import. Duplicate
|
|
766
|
+
pending events or an event already included in the mapped seen baseline reject
|
|
767
|
+
explicitly. Context registration, baseline and delivery commit in one SQLite
|
|
768
|
+
transaction under cross-process exclusion. Repeat attempts reject and cannot
|
|
769
|
+
overwrite the prior import, whether its pending batch is acknowledged or not.
|
|
770
|
+
|
|
771
|
+
`NotificationBootstrapResult(imported, unmapped, pending)` returns the successful
|
|
772
|
+
staged batch and receipt. After a lost response or restart, discover it with
|
|
773
|
+
`store.recovery_status(context=...)` and `store.pending_batch(context=...)`, or
|
|
774
|
+
replay it through
|
|
775
|
+
`NotificationWorkflow(client, store).poll(categories=(NotificationCategory.AGENDA,))`
|
|
776
|
+
without consume consent or network, then acknowledge its receipt normally.
|
|
777
|
+
`store.state(context=...)` exposes the mapped baseline; `export_archive` and
|
|
778
|
+
`import_archive` round-trip both historical and native pending work. Native
|
|
779
|
+
archives are still format 3; old records without `provenance` mean OBSERVED.
|
|
780
|
+
Older library versions cannot read the new historical representation.
|
|
781
|
+
|
|
782
|
+
For consumer-owned rollback, retain original source files and the mapping plan,
|
|
783
|
+
plus a native archive before cutover. Public replay returns the historical event
|
|
784
|
+
strings and receipt; public state returns mapped seen IDs. Native IDs are one-way
|
|
785
|
+
hashes, source identifiers are not persisted, and missing authentication metadata
|
|
786
|
+
cannot be recovered. Therefore a generic reverse migration to a legacy store is
|
|
787
|
+
not promised. Do not replace original files with this store or fabricate metadata
|
|
788
|
+
to make an old importer accept new state.
|
|
648
789
|
## Message content
|
|
649
790
|
|
|
650
791
|
`message_content(reference, *, allow_mark_read=False)` returns
|
|
@@ -1,5 +1,70 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.2 (2026-10-06) - Faster shared request policy
|
|
4
|
+
|
|
5
|
+
Compatible patch release. The public library API is unchanged.
|
|
6
|
+
|
|
7
|
+
- Raise the default `SchedulerLimits` to 10 requests/second, a shared burst of
|
|
8
|
+
20 and four simultaneous requests across the service (from 5, 10 and 2).
|
|
9
|
+
Each login still runs one request at a time, queue limits are unchanged, and
|
|
10
|
+
429/503 responses still pause all traffic and resume without a burst. Measured
|
|
11
|
+
with the MCP consumer on four logins, the earlier policy made real workloads
|
|
12
|
+
about 35% slower than the unthrottled apix-based client. Pass explicit lower
|
|
13
|
+
`SchedulerLimits` where stricter traffic is required.
|
|
14
|
+
|
|
15
|
+
## 1.0.1 (2026-10-06) - Bounded subject frequency and reliability fixes
|
|
16
|
+
|
|
17
|
+
Compatible patch release. The public library API is unchanged.
|
|
18
|
+
|
|
19
|
+
- Resolve subject-frequency lesson and subject names from the gateway `Lessons`
|
|
20
|
+
and `Subjects` collections, read once per login session and cached for the
|
|
21
|
+
existing one-hour TTL. A cold call now costs three requests regardless of how
|
|
22
|
+
many lessons and subjects a student has. Before, it cost one request per unique
|
|
23
|
+
lesson plus one per subject, which exceeded the default 32-request operation
|
|
24
|
+
budget near 15 subjects and failed with `LIMIT`. References missing from a
|
|
25
|
+
collection keep the strict per-reference routes. An unavailable, denied,
|
|
26
|
+
oversized or malformed collection falls back to them; deadlines, budgets and
|
|
27
|
+
cancellation still propagate.
|
|
28
|
+
- Report a published attachment as published when removing the private temporary
|
|
29
|
+
name fails after the atomic link commit, instead of raising `STORAGE` for a
|
|
30
|
+
complete file. The inert temporary file may remain.
|
|
31
|
+
- Wait up to one second, not 100 ms, for another process's SQLite write lock in
|
|
32
|
+
ordinary store operations. Several local processes commonly share one store;
|
|
33
|
+
final saves after upstream side effects keep their five-second wait.
|
|
34
|
+
|
|
35
|
+
## 1.0.0 (2026-10-05) - Stable native library API
|
|
36
|
+
|
|
37
|
+
Freeze the documented library API and compatibility policy for consumers moving
|
|
38
|
+
to native contracts. This is a library release, not proof of completed MCP 2.0
|
|
39
|
+
cutover or universal live-school compatibility. Existing school/layout limitations
|
|
40
|
+
and separately authorized live checks remain explicit. No live requests or
|
|
41
|
+
production-state migration occur during release qualification.
|
|
42
|
+
|
|
43
|
+
- Require sealed Windows disk qualification in addition to Linux/macOS release
|
|
44
|
+
checks, on Python 3.13/3.14 with locked and newest permitted dependencies.
|
|
45
|
+
- Preserve explicit account keys, format-3 state and conservative UNKNOWN sending
|
|
46
|
+
history. No automatic old-store import, silent notification reset or rollback.
|
|
47
|
+
|
|
48
|
+
- Support optional Windows disk workflows on fixed local NTFS: private protected
|
|
49
|
+
inheritable ACLs, pinned directory/ancestor handles, cross-process notification
|
|
50
|
+
locks and complete non-overwriting attachment publication by handle. Add explicit
|
|
51
|
+
private attachment-directory preparation and Windows-only pywin32/tzdata runtime
|
|
52
|
+
dependencies. Reject network namespaces, unsafe ACLs and reparse-point paths.
|
|
53
|
+
|
|
54
|
+
- Add compact, account-bound offline notification recovery status and pending-batch
|
|
55
|
+
lookup. Discover original categories/backend/receipt, retained raw progress and
|
|
56
|
+
uncertain consumption without remembered selections or implicit acknowledgement.
|
|
57
|
+
|
|
58
|
+
- Add a neutral offline notification bootstrap API for explicitly mapped baseline
|
|
59
|
+
IDs and consumed historical events. Report unmapped IDs without writes; atomically
|
|
60
|
+
stage bounded pending history with explicit missing provenance. Preserve restart,
|
|
61
|
+
archive round-trip and acknowledgement without fabricating native metadata.
|
|
62
|
+
|
|
63
|
+
- Add explicit legacy/modern mailbox selection to durable notification workflows.
|
|
64
|
+
Modern summaries use their own canonical ID domain; pending batches retain their
|
|
65
|
+
original source across restart and archive import. Existing legacy IDs and
|
|
66
|
+
format-3 state remain readable without reset or rewriting seen history.
|
|
67
|
+
|
|
3
68
|
## 1.0.0rc1 (2026-10-05) - Library-only release candidate
|
|
4
69
|
|
|
5
70
|
First candidate prepared for PyPI Trusted Publishing. The supported library API
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: librus-python-api
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.2
|
|
4
4
|
Summary: Independent bounded async Librus Synergia client
|
|
5
5
|
Project-URL: Repository, https://github.com/krzysztofbury/librus-python-api
|
|
6
6
|
Project-URL: Issues, https://github.com/krzysztofbury/librus-python-api/issues
|
|
7
7
|
License-Expression: MIT
|
|
8
8
|
License-File: LICENSE
|
|
9
|
-
Classifier: Development Status ::
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
10
|
Classifier: Framework :: AsyncIO
|
|
11
11
|
Classifier: Intended Audience :: Developers
|
|
12
12
|
Classifier: Operating System :: MacOS :: MacOS X
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
13
14
|
Classifier: Operating System :: POSIX
|
|
14
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
15
16
|
Classifier: Programming Language :: Python :: 3.14
|
|
@@ -18,6 +19,8 @@ Requires-Python: >=3.13
|
|
|
18
19
|
Requires-Dist: aiohttp<4,>=3.14.2
|
|
19
20
|
Requires-Dist: lxml<7,>=6.1
|
|
20
21
|
Requires-Dist: pydantic<3,>=2.12
|
|
22
|
+
Requires-Dist: pywin32<400,>=312; sys_platform == 'win32'
|
|
23
|
+
Requires-Dist: tzdata>=2026.5; sys_platform == 'win32'
|
|
21
24
|
Requires-Dist: yarl<2,>=1.20
|
|
22
25
|
Description-Content-Type: text/markdown
|
|
23
26
|
|
|
@@ -31,25 +34,28 @@ Use it in personal scripts, notification services or application backends. One
|
|
|
31
34
|
service can manage multiple logins while keeping their sessions and data separate.
|
|
32
35
|
It is not an official Librus product.
|
|
33
36
|
|
|
34
|
-
**Requirements:** Python 3.13 or newer.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
**Requirements:** Python 3.13 or newer. Disk workflows require a trusted local
|
|
38
|
+
POSIX filesystem on Linux/macOS, or a fixed local NTFS volume on Windows with
|
|
39
|
+
private ACLs. Windows installations include `pywin32` and `tzdata` automatically.
|
|
40
|
+
Network paths, reparse-point paths and unsafe storage permissions fail closed.
|
|
41
|
+
See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
|
|
37
42
|
|
|
38
|
-
**Status:** `1.0.
|
|
39
|
-
|
|
40
|
-
remain
|
|
43
|
+
**Status:** `1.0.x` freezes the documented native library API. It does not claim
|
|
44
|
+
completed MCP cutover, production-state migration or universal live-school
|
|
45
|
+
compatibility. Those consumer/live gates remain separate. School features depend
|
|
46
|
+
on what each account can access. See the [compatibility policy](API.md#compatibility-policy).
|
|
41
47
|
See [limitations](#supported-features-and-limitations) below.
|
|
42
48
|
|
|
43
49
|
## Install
|
|
44
50
|
|
|
45
|
-
Install the exact
|
|
51
|
+
Install the exact release from PyPI after its gated publication completes:
|
|
46
52
|
|
|
47
53
|
```sh
|
|
48
|
-
python -m pip install librus-python-api==1.0.
|
|
54
|
+
python -m pip install librus-python-api==1.0.2
|
|
49
55
|
```
|
|
50
56
|
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
Pin the exact version qualified by your application. Before publication, use a
|
|
58
|
+
checkout or locally built wheel instead.
|
|
53
59
|
From a checkout, install into a virtual environment:
|
|
54
60
|
|
|
55
61
|
```sh
|
|
@@ -61,7 +67,7 @@ python -m pip install .
|
|
|
61
67
|
To install a locally built wheel instead:
|
|
62
68
|
|
|
63
69
|
```sh
|
|
64
|
-
python -m pip install ./dist/librus_python_api-1.0.
|
|
70
|
+
python -m pip install ./dist/librus_python_api-1.0.2-py3-none-any.whl
|
|
65
71
|
```
|
|
66
72
|
|
|
67
73
|
No CLI or background process is installed: import the library in your own program.
|
|
@@ -177,7 +183,8 @@ except LibrusError as error:
|
|
|
177
183
|
```
|
|
178
184
|
|
|
179
185
|
A budget covers login, queueing and all pages of an operation. Defaults allow
|
|
180
|
-
|
|
186
|
+
10 requests/second, a burst of 20 and four simultaneous requests across the
|
|
187
|
+
service, with one at a time per login.
|
|
181
188
|
Fresh reads are the default. Unsupported layouts raise typed errors rather than
|
|
182
189
|
silently returning incomplete data. Cursors detect changes; they are not snapshots.
|
|
183
190
|
|
|
@@ -192,8 +199,10 @@ silently returning incomplete data. Cursors detect changes; they are not snapsho
|
|
|
192
199
|
- **Notifications:** optional `NotificationStore` and `NotificationWorkflow`
|
|
193
200
|
provide durable checkpoints, pending delivery and explicit acknowledgement.
|
|
194
201
|
- **Persistent sends:** optional `PersistenceStore` records confirmations, claims
|
|
195
|
-
and uncertain outcomes across restarts.
|
|
196
|
-
directories
|
|
202
|
+
and uncertain outcomes across restarts. Stores create or validate private
|
|
203
|
+
caller-selected directories; core reads do not create files. Use explicit
|
|
204
|
+
`files.prepare_attachment_directory(path)` to provision a private download
|
|
205
|
+
directory without writing platform-specific ACL code.
|
|
197
206
|
|
|
198
207
|
The legacy and modern messaging backends have distinct references and permissions;
|
|
199
208
|
select one explicitly. See the [API reference][api] for complete workflows.
|
|
@@ -8,25 +8,28 @@ Use it in personal scripts, notification services or application backends. One
|
|
|
8
8
|
service can manage multiple logins while keeping their sessions and data separate.
|
|
9
9
|
It is not an official Librus product.
|
|
10
10
|
|
|
11
|
-
**Requirements:** Python 3.13 or newer.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
**Requirements:** Python 3.13 or newer. Disk workflows require a trusted local
|
|
12
|
+
POSIX filesystem on Linux/macOS, or a fixed local NTFS volume on Windows with
|
|
13
|
+
private ACLs. Windows installations include `pywin32` and `tzdata` automatically.
|
|
14
|
+
Network paths, reparse-point paths and unsafe storage permissions fail closed.
|
|
15
|
+
See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
|
|
16
|
+
|
|
17
|
+
**Status:** `1.0.x` freezes the documented native library API. It does not claim
|
|
18
|
+
completed MCP cutover, production-state migration or universal live-school
|
|
19
|
+
compatibility. Those consumer/live gates remain separate. School features depend
|
|
20
|
+
on what each account can access. See the [compatibility policy](API.md#compatibility-policy).
|
|
18
21
|
See [limitations](#supported-features-and-limitations) below.
|
|
19
22
|
|
|
20
23
|
## Install
|
|
21
24
|
|
|
22
|
-
Install the exact
|
|
25
|
+
Install the exact release from PyPI after its gated publication completes:
|
|
23
26
|
|
|
24
27
|
```sh
|
|
25
|
-
python -m pip install librus-python-api==1.0.
|
|
28
|
+
python -m pip install librus-python-api==1.0.2
|
|
26
29
|
```
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
Pin the exact version qualified by your application. Before publication, use a
|
|
32
|
+
checkout or locally built wheel instead.
|
|
30
33
|
From a checkout, install into a virtual environment:
|
|
31
34
|
|
|
32
35
|
```sh
|
|
@@ -38,7 +41,7 @@ python -m pip install .
|
|
|
38
41
|
To install a locally built wheel instead:
|
|
39
42
|
|
|
40
43
|
```sh
|
|
41
|
-
python -m pip install ./dist/librus_python_api-1.0.
|
|
44
|
+
python -m pip install ./dist/librus_python_api-1.0.2-py3-none-any.whl
|
|
42
45
|
```
|
|
43
46
|
|
|
44
47
|
No CLI or background process is installed: import the library in your own program.
|
|
@@ -154,7 +157,8 @@ except LibrusError as error:
|
|
|
154
157
|
```
|
|
155
158
|
|
|
156
159
|
A budget covers login, queueing and all pages of an operation. Defaults allow
|
|
157
|
-
|
|
160
|
+
10 requests/second, a burst of 20 and four simultaneous requests across the
|
|
161
|
+
service, with one at a time per login.
|
|
158
162
|
Fresh reads are the default. Unsupported layouts raise typed errors rather than
|
|
159
163
|
silently returning incomplete data. Cursors detect changes; they are not snapshots.
|
|
160
164
|
|
|
@@ -169,8 +173,10 @@ silently returning incomplete data. Cursors detect changes; they are not snapsho
|
|
|
169
173
|
- **Notifications:** optional `NotificationStore` and `NotificationWorkflow`
|
|
170
174
|
provide durable checkpoints, pending delivery and explicit acknowledgement.
|
|
171
175
|
- **Persistent sends:** optional `PersistenceStore` records confirmations, claims
|
|
172
|
-
and uncertain outcomes across restarts.
|
|
173
|
-
directories
|
|
176
|
+
and uncertain outcomes across restarts. Stores create or validate private
|
|
177
|
+
caller-selected directories; core reads do not create files. Use explicit
|
|
178
|
+
`files.prepare_attachment_directory(path)` to provision a private download
|
|
179
|
+
directory without writing platform-specific ACL code.
|
|
174
180
|
|
|
175
181
|
The legacy and modern messaging backends have distinct references and permissions;
|
|
176
182
|
select one explicitly. See the [API reference][api] for complete workflows.
|
|
@@ -11,16 +11,17 @@ requires-python = ">=3.13"
|
|
|
11
11
|
license = "MIT"
|
|
12
12
|
license-files = ["LICENSE"]
|
|
13
13
|
classifiers = [
|
|
14
|
-
"Development Status ::
|
|
14
|
+
"Development Status :: 5 - Production/Stable",
|
|
15
15
|
"Framework :: AsyncIO",
|
|
16
16
|
"Intended Audience :: Developers",
|
|
17
17
|
"Operating System :: POSIX",
|
|
18
18
|
"Operating System :: MacOS :: MacOS X",
|
|
19
|
+
"Operating System :: Microsoft :: Windows",
|
|
19
20
|
"Programming Language :: Python :: 3.13",
|
|
20
21
|
"Programming Language :: Python :: 3.14",
|
|
21
22
|
"Typing :: Typed",
|
|
22
23
|
]
|
|
23
|
-
dependencies = ["aiohttp>=3.14.2,<4", "lxml>=6.1,<7", "pydantic>=2.12,<3", "yarl>=1.20,<2"]
|
|
24
|
+
dependencies = ["aiohttp>=3.14.2,<4", "lxml>=6.1,<7", "pydantic>=2.12,<3", "yarl>=1.20,<2", "pywin32>=312,<400; sys_platform == 'win32'", "tzdata>=2026.5; sys_platform == 'win32'"]
|
|
24
25
|
|
|
25
26
|
[project.urls]
|
|
26
27
|
Repository = "https://github.com/krzysztofbury/librus-python-api"
|
|
@@ -37,6 +38,7 @@ dev = [
|
|
|
37
38
|
"ruff>=0.15,<0.17",
|
|
38
39
|
"types-pyyaml>=6,<7",
|
|
39
40
|
"types-lxml>=2026.2.16,<2027",
|
|
41
|
+
"types-pywin32>=312,<400",
|
|
40
42
|
"pre-commit>=4.6,<5",
|
|
41
43
|
"pip-audit>=2.10,<3",
|
|
42
44
|
]
|
|
@@ -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.
|
|
123
|
+
__version__ = "1.0.2"
|
|
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)
|