librus-python-api 1.0.0rc1__tar.gz → 1.0.1__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.
Files changed (54) hide show
  1. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/API.md +153 -12
  2. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/CHANGELOG.md +53 -0
  3. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/PKG-INFO +23 -15
  4. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/README.md +19 -14
  5. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/pyproject.toml +4 -2
  6. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/__init__.py +1 -1
  7. librus_python_api-1.0.1/src/librus_python_api/_notification_bootstrap.py +124 -0
  8. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/_notification_codec.py +99 -13
  9. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/_storage.py +42 -3
  10. librus_python_api-1.0.1/src/librus_python_api/_windows_filesystem.py +390 -0
  11. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/attendance_frequency.py +65 -2
  12. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/config.py +19 -0
  13. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/files.py +108 -17
  14. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/models.py +20 -0
  15. librus_python_api-1.0.1/src/librus_python_api/notification_models.py +145 -0
  16. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/notification_persistence.py +230 -31
  17. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/notification_workflow.py +41 -7
  18. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/persistence.py +14 -0
  19. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/service.py +53 -3
  20. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/uv.lock +40 -0
  21. librus_python_api-1.0.0rc1/src/librus_python_api/notification_models.py +0 -67
  22. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/.gitignore +0 -0
  23. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/LICENSE +0 -0
  24. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/SECURITY.md +0 -0
  25. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/announcements.py +0 -0
  26. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/attachment_routes.py +0 -0
  27. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/attachments.py +0 -0
  28. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/attendance.py +0 -0
  29. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/budget.py +0 -0
  30. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/checkpoint.py +0 -0
  31. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/completed_lessons.py +0 -0
  32. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/detail_fields.py +0 -0
  33. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/diagnostics.py +0 -0
  34. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/exceptions.py +0 -0
  35. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/grade_parsers.py +0 -0
  36. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/grade_records.py +0 -0
  37. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/homework_range.py +0 -0
  38. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/lifecycle.py +0 -0
  39. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/markup.py +0 -0
  40. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/message_content.py +0 -0
  41. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/messages.py +0 -0
  42. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/modern_body.py +0 -0
  43. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/modern_mailbox.py +0 -0
  44. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/modern_messages.py +0 -0
  45. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/notifications.py +0 -0
  46. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/parsers.py +0 -0
  47. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/parsing.py +0 -0
  48. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/py.typed +0 -0
  49. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/recipients.py +0 -0
  50. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/scheduler.py +0 -0
  51. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/school_reads.py +0 -0
  52. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/sending.py +0 -0
  53. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/src/librus_python_api/timetable.py +0 -0
  54. {librus_python_api-1.0.0rc1 → librus_python_api-1.0.1}/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
@@ -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; other platforms fail with
473
- `UnsupportedCapabilityError` before creating files.
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/migrate state.
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` and `canonical_notification_id` from
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/POSIX flock support; other platforms fail explicitly on
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).poll(*, categories, allow_consume_events=False,
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, messages use received page zero,
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 batch. Each `NotificationItem` carries
604
- category, canonical identifier, native typed value, identity and observation.
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 delivery.
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.export_archive(context=...)` returns a version-1 neutral archive
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)` exposes version-1 native identities.
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,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.1 (2026-10-06) - Bounded subject frequency and reliability fixes
4
+
5
+ Compatible patch release. The public library API is unchanged.
6
+
7
+ - Resolve subject-frequency lesson and subject names from the gateway `Lessons`
8
+ and `Subjects` collections, read once per login session and cached for the
9
+ existing one-hour TTL. A cold call now costs three requests regardless of how
10
+ many lessons and subjects a student has. Before, it cost one request per unique
11
+ lesson plus one per subject, which exceeded the default 32-request operation
12
+ budget near 15 subjects and failed with `LIMIT`. References missing from a
13
+ collection keep the strict per-reference routes. An unavailable, denied,
14
+ oversized or malformed collection falls back to them; deadlines, budgets and
15
+ cancellation still propagate.
16
+ - Report a published attachment as published when removing the private temporary
17
+ name fails after the atomic link commit, instead of raising `STORAGE` for a
18
+ complete file. The inert temporary file may remain.
19
+ - Wait up to one second, not 100 ms, for another process's SQLite write lock in
20
+ ordinary store operations. Several local processes commonly share one store;
21
+ final saves after upstream side effects keep their five-second wait.
22
+
23
+ ## 1.0.0 (2026-10-05) - Stable native library API
24
+
25
+ Freeze the documented library API and compatibility policy for consumers moving
26
+ to native contracts. This is a library release, not proof of completed MCP 2.0
27
+ cutover or universal live-school compatibility. Existing school/layout limitations
28
+ and separately authorized live checks remain explicit. No live requests or
29
+ production-state migration occur during release qualification.
30
+
31
+ - Require sealed Windows disk qualification in addition to Linux/macOS release
32
+ checks, on Python 3.13/3.14 with locked and newest permitted dependencies.
33
+ - Preserve explicit account keys, format-3 state and conservative UNKNOWN sending
34
+ history. No automatic old-store import, silent notification reset or rollback.
35
+
36
+ - Support optional Windows disk workflows on fixed local NTFS: private protected
37
+ inheritable ACLs, pinned directory/ancestor handles, cross-process notification
38
+ locks and complete non-overwriting attachment publication by handle. Add explicit
39
+ private attachment-directory preparation and Windows-only pywin32/tzdata runtime
40
+ dependencies. Reject network namespaces, unsafe ACLs and reparse-point paths.
41
+
42
+ - Add compact, account-bound offline notification recovery status and pending-batch
43
+ lookup. Discover original categories/backend/receipt, retained raw progress and
44
+ uncertain consumption without remembered selections or implicit acknowledgement.
45
+
46
+ - Add a neutral offline notification bootstrap API for explicitly mapped baseline
47
+ IDs and consumed historical events. Report unmapped IDs without writes; atomically
48
+ stage bounded pending history with explicit missing provenance. Preserve restart,
49
+ archive round-trip and acknowledgement without fabricating native metadata.
50
+
51
+ - Add explicit legacy/modern mailbox selection to durable notification workflows.
52
+ Modern summaries use their own canonical ID domain; pending batches retain their
53
+ original source across restart and archive import. Existing legacy IDs and
54
+ format-3 state remain readable without reset or rewriting seen history.
55
+
3
56
  ## 1.0.0rc1 (2026-10-05) - Library-only release candidate
4
57
 
5
58
  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.0rc1
3
+ Version: 1.0.1
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 :: 4 - Beta
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. Linux and macOS are tested in CI.
35
- Persistence and saving attachments require POSIX filesystem features, such as
36
- those on Linux or macOS; Windows disk workflows are unsupported.
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.0rc1`, a library-only beta prerelease, not stable 1.0 or an MCP
39
- cutover. MCP integration, legacy-state migration and broader live qualification
40
- remain unfinished. School features depend on what each account can access.
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 candidate from PyPI once its gated publication completes:
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.0rc1
54
+ python -m pip install librus-python-api==1.0.1
49
55
  ```
50
56
 
51
- An explicit version selects the prerelease without opting into every prerelease
52
- dependency. Before publication, use a checkout or locally built wheel instead.
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.0rc1-py3-none-any.whl
70
+ python -m pip install ./dist/librus_python_api-1.0.1-py3-none-any.whl
65
71
  ```
66
72
 
67
73
  No CLI or background process is installed: import the library in your own program.
@@ -192,8 +198,10 @@ silently returning incomplete data. Cursors detect changes; they are not snapsho
192
198
  - **Notifications:** optional `NotificationStore` and `NotificationWorkflow`
193
199
  provide durable checkpoints, pending delivery and explicit acknowledgement.
194
200
  - **Persistent sends:** optional `PersistenceStore` records confirmations, claims
195
- and uncertain outcomes across restarts. These stores require private POSIX
196
- directories. Core reads do not create files.
201
+ and uncertain outcomes across restarts. Stores create or validate private
202
+ caller-selected directories; core reads do not create files. Use explicit
203
+ `files.prepare_attachment_directory(path)` to provision a private download
204
+ directory without writing platform-specific ACL code.
197
205
 
198
206
  The legacy and modern messaging backends have distinct references and permissions;
199
207
  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. Linux and macOS are tested in CI.
12
- Persistence and saving attachments require POSIX filesystem features, such as
13
- those on Linux or macOS; Windows disk workflows are unsupported.
14
-
15
- **Status:** `1.0.0rc1`, a library-only beta prerelease, not stable 1.0 or an MCP
16
- cutover. MCP integration, legacy-state migration and broader live qualification
17
- remain unfinished. School features depend on what each account can access.
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 candidate from PyPI once its gated publication completes:
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.0rc1
28
+ python -m pip install librus-python-api==1.0.1
26
29
  ```
27
30
 
28
- An explicit version selects the prerelease without opting into every prerelease
29
- dependency. Before publication, use a checkout or locally built wheel instead.
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.0rc1-py3-none-any.whl
44
+ python -m pip install ./dist/librus_python_api-1.0.1-py3-none-any.whl
42
45
  ```
43
46
 
44
47
  No CLI or background process is installed: import the library in your own program.
@@ -169,8 +172,10 @@ silently returning incomplete data. Cursors detect changes; they are not snapsho
169
172
  - **Notifications:** optional `NotificationStore` and `NotificationWorkflow`
170
173
  provide durable checkpoints, pending delivery and explicit acknowledgement.
171
174
  - **Persistent sends:** optional `PersistenceStore` records confirmations, claims
172
- and uncertain outcomes across restarts. These stores require private POSIX
173
- directories. Core reads do not create files.
175
+ and uncertain outcomes across restarts. Stores create or validate private
176
+ caller-selected directories; core reads do not create files. Use explicit
177
+ `files.prepare_attachment_directory(path)` to provision a private download
178
+ directory without writing platform-specific ACL code.
174
179
 
175
180
  The legacy and modern messaging backends have distinct references and permissions;
176
181
  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 :: 4 - Beta",
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.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)