perceptkit 0.2.7__tar.gz → 0.2.8__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 (112) hide show
  1. {perceptkit-0.2.7 → perceptkit-0.2.8}/CHANGELOG.md +24 -0
  2. {perceptkit-0.2.7 → perceptkit-0.2.8}/PKG-INFO +1 -1
  3. {perceptkit-0.2.7 → perceptkit-0.2.8}/pyproject.toml +1 -1
  4. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/__init__.py +5 -0
  5. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/kit.py +18 -0
  6. perceptkit-0.2.8/src/perceptkit/processing/source_sync.py +227 -0
  7. perceptkit-0.2.8/tests/test_source_sync.py +251 -0
  8. {perceptkit-0.2.7 → perceptkit-0.2.8}/uv.lock +2 -2
  9. {perceptkit-0.2.7 → perceptkit-0.2.8}/.github/workflows/ci.yml +0 -0
  10. {perceptkit-0.2.7 → perceptkit-0.2.8}/.github/workflows/release.yml +0 -0
  11. {perceptkit-0.2.7 → perceptkit-0.2.8}/.gitignore +0 -0
  12. {perceptkit-0.2.7 → perceptkit-0.2.8}/LICENSE +0 -0
  13. {perceptkit-0.2.7 → perceptkit-0.2.8}/NOTES-packaging.md +0 -0
  14. {perceptkit-0.2.7 → perceptkit-0.2.8}/NOTES-quickstart.md +0 -0
  15. {perceptkit-0.2.7 → perceptkit-0.2.8}/README.md +0 -0
  16. {perceptkit-0.2.7 → perceptkit-0.2.8}/docs/PerceptKit-/344/272/247/345/223/201/347/233/256/346/240/207/344/270/216/345/275/223/345/211/215/345/256/236/347/216/260/345/267/256/350/267/235/345/217/215/351/246/210.md" +0 -0
  17. {perceptkit-0.2.7 → perceptkit-0.2.8}/docs/PerceptKit-/346/204/237/347/237/245/345/255/227/346/256/265/344/270/216/345/255/230/345/202/250-/345/267/245/347/250/213/345/257/271/351/275/220/350/241/245/345/205/205/350/256/250/350/256/272.md" +0 -0
  18. {perceptkit-0.2.7 → perceptkit-0.2.8}/docs/reference-storage-mapping.md +0 -0
  19. {perceptkit-0.2.7 → perceptkit-0.2.8}/examples/end_to_end.py +0 -0
  20. {perceptkit-0.2.7 → perceptkit-0.2.8}/examples/ios_adapter.py +0 -0
  21. {perceptkit-0.2.7 → perceptkit-0.2.8}/examples/quickstart.py +0 -0
  22. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/algorithms/__init__.py +0 -0
  23. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/algorithms/attribution.py +0 -0
  24. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/algorithms/glance.py +0 -0
  25. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/algorithms/history.py +0 -0
  26. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/algorithms/identity.py +0 -0
  27. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/algorithms/observation.py +0 -0
  28. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/algorithms/streaks.py +0 -0
  29. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/algorithms/trend_models.py +0 -0
  30. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/algorithms/wake.py +0 -0
  31. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/catalog.py +0 -0
  32. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/conformance/__init__.py +0 -0
  33. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/conformance/memory.py +0 -0
  34. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/conformance/report.py +0 -0
  35. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/conformance/suite.py +0 -0
  36. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/conformance/wake.py +0 -0
  37. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/contracts/__init__.py +0 -0
  38. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/contracts/_time.py +0 -0
  39. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/contracts/availability.py +0 -0
  40. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/contracts/context.py +0 -0
  41. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/contracts/delivery.py +0 -0
  42. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/contracts/errors.py +0 -0
  43. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/contracts/event.py +0 -0
  44. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/contracts/observation.py +0 -0
  45. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/contracts/receipt.py +0 -0
  46. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/contracts/records.py +0 -0
  47. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/contracts/report.py +0 -0
  48. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/contracts/versioning.py +0 -0
  49. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/fields.py +0 -0
  50. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/manifest/__init__.py +0 -0
  51. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/manifest/checks.py +0 -0
  52. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/manifest/mapping.py +0 -0
  53. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/manifest/minimal.py +0 -0
  54. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/manifest/types.py +0 -0
  55. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/manifest/units.py +0 -0
  56. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/ports/__init__.py +0 -0
  57. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/ports/storage.py +0 -0
  58. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/ports/wake.py +0 -0
  59. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/processing/__init__.py +0 -0
  60. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/processing/aggregate.py +0 -0
  61. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/processing/dispatch.py +0 -0
  62. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/processing/normalize.py +0 -0
  63. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/processing/pipeline.py +0 -0
  64. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/processing/recompute.py +0 -0
  65. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/processing/recurrence.py +0 -0
  66. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/processing/scheduled.py +0 -0
  67. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/prompts.py +0 -0
  68. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/queries/__init__.py +0 -0
  69. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/queries/api.py +0 -0
  70. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/retention.py +0 -0
  71. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/rules/__init__.py +0 -0
  72. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/rules/engine.py +0 -0
  73. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/rules/evaluators.py +0 -0
  74. {perceptkit-0.2.7 → perceptkit-0.2.8}/src/perceptkit/rules/types.py +0 -0
  75. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/fixtures/README.md +0 -0
  76. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/fixtures/ios_snapshot_no_data.json +0 -0
  77. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/fixtures/ios_snapshot_normal.json +0 -0
  78. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/fixtures/ios_snapshot_unauthorized.json +0 -0
  79. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_attribution.py +0 -0
  80. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_catalog.py +0 -0
  81. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_conformance.py +0 -0
  82. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_conformance_wake_report.py +0 -0
  83. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_contracts.py +0 -0
  84. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_delivery_and_records.py +0 -0
  85. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_docs_match_code.py +0 -0
  86. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_edge_cases.py +0 -0
  87. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_end_to_end.py +0 -0
  88. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_event_envelope.py +0 -0
  89. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_examples.py +0 -0
  90. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_export.py +0 -0
  91. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_identity.py +0 -0
  92. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_ios_fixture.py +0 -0
  93. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_isolation.py +0 -0
  94. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_manifest.py +0 -0
  95. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_no_host_leakage.py +0 -0
  96. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_observation.py +0 -0
  97. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_pipeline.py +0 -0
  98. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_projection.py +0 -0
  99. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_purity.py +0 -0
  100. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_queries.py +0 -0
  101. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_recompute.py +0 -0
  102. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_recurrence.py +0 -0
  103. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_regressions.py +0 -0
  104. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_retention.py +0 -0
  105. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_retention_entry.py +0 -0
  106. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_revision_recompute.py +0 -0
  107. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_rules.py +0 -0
  108. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_scheduled.py +0 -0
  109. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_source_mirror.py +0 -0
  110. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_streaks.py +0 -0
  111. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_trend_models.py +0 -0
  112. {perceptkit-0.2.7 → perceptkit-0.2.8}/tests/test_wake.py +0 -0
@@ -1,5 +1,29 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.2.8 — 2026-09-02
4
+
5
+ - **来源镜像的同步终于有了和 ``ingest()`` 对等的入口(P0-2)。**
6
+ 写入侧原本只有四个原语,于是每个宿主自己拼「收数据 → upsert → 写同步状态
7
+ → 全量按范围删 → 处理失败」这一串。而这条路上**每个坑错了都不报错,
8
+ 且大多不可逆**:
9
+
10
+ 增量当全量删 用户发现自己去年的日程凭空消失了,系统一切正常
11
+ 失败还推进游标 那段数据永远不会再被同步一次,也没人记得它缺过
12
+ 失败还删条目 来源临时不可达被当成"来源侧删光了"
13
+ 删了但没提交 镜像少一批、状态说成功,下轮增量不会补
14
+ 没有覆盖范围就删 "全量"是相对某个范围说的,没范围就是删全部
15
+ 条目没打这轮的 id 刚写进去的被自己的快照收尾删掉,一次"成功"的
16
+ 全量同步把镜像清空了(写这个模块时真踩到的)
17
+
18
+ ``SyncBatch`` 的字段就是审查里列的那份契约(source / collection_kind /
19
+ sync_id / snapshot_kind / coverage_start / coverage_end / cursor / items /
20
+ attempted_at / completed_at / error_code)。
21
+ ``PerceptionKit.sync_source_mirror(batch, context=...)`` 编排 upsert、
22
+ 范围内删除和同步状态,三个动作在同一个事务里。
23
+
24
+ **不解析来源格式** —— 苹果日历、Google、Exchange 的条目长得完全不一样,
25
+ 翻译成标准镜像记录仍然是宿主的活。
26
+
3
27
  ## 0.2.7 — 2026-09-02
4
28
 
5
29
  - **跳过原因带上稳定的 `code`(`SkippedSignal`)。** 0.2.6 的清理计划里,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: perceptkit
3
- Version: 0.2.7
3
+ Version: 0.2.8
4
4
  Summary: 从设备信号判断:有没有发生值得留意的事、值不值得叫醒一次 agent、以及该怎么把此刻的状况讲给它听。不采集数据、不选数据库、不调模型 —— 存储由宿主实现 StoragePort,编排在包内。
5
5
  License:
6
6
  Apache License
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "perceptkit"
7
- version = "0.2.7"
7
+ version = "0.2.8"
8
8
  description = "从设备信号判断:有没有发生值得留意的事、值不值得叫醒一次 agent、以及该怎么把此刻的状况讲给它听。不采集数据、不选数据库、不调模型 —— 存储由宿主实现 StoragePort,编排在包内。"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -33,6 +33,9 @@ from .algorithms.attribution import attribute_episode, attribute_instant, split_
33
33
  from .catalog import CAPABILITIES, SIGNALS
34
34
  from .contracts import IngestContext, Observation, PerceptionEvent, ReportEnvelope, WakeReceipt
35
35
  from .kit import PerceptionKit
36
+ from .processing.source_sync import (
37
+ FULL, INCREMENTAL, SyncBatch, SyncContractError, SyncOutcome,
38
+ )
36
39
  from .fields import AGENT_PERCEPTION_SIGNALS, project_signal
37
40
  from .algorithms.glance import build_perception_glance
38
41
  from .algorithms.history import is_historized
@@ -53,6 +56,8 @@ from .algorithms.wake import is_wake_worthy_signal, is_significant_change, shoul
53
56
  __all__ = [
54
57
  # 接入口
55
58
  "PerceptionKit",
59
+ "SyncBatch", "SyncOutcome", "SyncContractError",
60
+ "FULL", "INCREMENTAL",
56
61
  "ReportEnvelope", "Observation", "PerceptionEvent", "WakeReceipt", "IngestContext",
57
62
  "contracts", "manifest", "ports", "processing", "rules",
58
63
  # 算法
@@ -26,6 +26,7 @@ from .ports.wake import WakePort
26
26
  from .processing.dispatch import DispatchOutcome, drain
27
27
  from .processing.pipeline import AGGREGATION_VERSION, IngestOutcome, ingest_report
28
28
  from .processing.recompute import RecomputeOutcome, recompute_range
29
+ from .processing.source_sync import sync_source_mirror
29
30
  from .retention import plan_retention
30
31
  from .processing.scheduled import ScheduledOutcome, evaluate_absence, evaluate_daily
31
32
  from .queries import api as _queries
@@ -143,6 +144,23 @@ class PerceptionKit:
143
144
  now=now, allow_incomplete=allow_incomplete,
144
145
  )
145
146
 
147
+ def sync_source_mirror(self, batch, *, context: IngestContext):
148
+ """把一批日历/提醒的来源数据落进镜像,并推进同步状态。
149
+
150
+ 和 :meth:`ingest` 对等的那个入口 —— 来源镜像走的是完全不同的一条路
151
+ (它存「来源现在有哪些条目」,不是「我们每次看到了什么」),
152
+ 但同样有一串**错了不报错、而且大多不可逆**的规则:增量不许删、
153
+ 全量只在自己声明的范围内删、失败的批次什么都不动也不推进游标、
154
+ 三个动作必须在同一个事务里。
155
+
156
+ 见 ``processing.source_sync`` 的模块文档 —— 每条规则都配了它对应的
157
+ 那个故障长什么样。
158
+
159
+ **不解析来源格式。** 苹果日历、Google、Exchange 的条目长得完全不一样,
160
+ 翻译成标准镜像记录是宿主的活。
161
+ """
162
+ return sync_source_mirror(self.storage, batch, context=context)
163
+
146
164
  def run_retention(
147
165
  self, *, subject_id: str, now: datetime, dry_run: bool = True,
148
166
  ) -> dict[str, Any]:
@@ -0,0 +1,227 @@
1
+ """来源镜像的同步编排 —— 日历和提醒走的那条路。
2
+
3
+ 写入侧原本只有四个原语(``upsert_calendar_events`` / ``upsert_reminders`` /
4
+ ``apply_source_snapshot`` / ``put_sync_state``),没有和 ``ingest()`` 对等的
5
+ 公开入口。于是每个宿主自己拼:
6
+
7
+ 收到来源数据 → 解析身份 → upsert → 写同步状态 → 全量时按范围删 → 处理失败
8
+
9
+ **这条路上的每个坑,错了都不报错,而且大多不可逆。** 那正是它该收进 kit 的理由:
10
+
11
+ 增量当全量删 拿一个局部窗口去删窗口外的数据 —— 用户发现自己去年的
12
+ 日程凭空消失了,而系统一切正常
13
+ 失败还推进游标 这一批没拿到,游标却往前走了 —— 那段数据**永远**不会
14
+ 再被同步一次,而且没有任何地方记得它缺过
15
+ 失败还删条目 来源临时不可达被当成"来源侧删光了"
16
+ 删了但没提交 镜像里少一批条目、同步状态却说这轮成功了;
17
+ 下一轮增量同步不会补,因为它以为上一轮是完整的
18
+ 没有覆盖范围就删 "全量"是相对于某个范围说的。没有范围的全量删除,
19
+ 删的是这个用户在这个来源下的**全部**条目
20
+
21
+ 所以这个模块只做一件事:把这几条规则变成一条路,而不是六个宿主各写一遍。
22
+
23
+ **它不解析来源格式。** 苹果日历、Google、Exchange 的条目长得完全不一样,
24
+ 翻译成 ``CalendarEventMirror`` / ``ReminderItemMirror`` 是宿主的活 ——
25
+ kit 只管拿到标准条目之后的顺序和边界。
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass, field, replace
30
+ from datetime import datetime
31
+ from typing import Any, Sequence
32
+
33
+ from ..contracts.context import IngestContext
34
+ from ..contracts.records import (
35
+ CalendarEventMirror,
36
+ ReminderItemMirror,
37
+ SourceSyncState,
38
+ )
39
+ from ..ports.storage import StoragePort
40
+
41
+ #: 全量:这一批覆盖范围内的条目就是来源的全部,范围内没见到的可以删。
42
+ FULL = "full"
43
+ #: 增量:只带了变化的部分,**一条都不许删**。
44
+ INCREMENTAL = "incremental"
45
+
46
+ CALENDAR = "calendar"
47
+ REMINDERS = "reminders"
48
+
49
+
50
+ class SyncContractError(ValueError):
51
+ """这一批的声明本身自相矛盾,处理它会造成不可逆的损失。
52
+
53
+ 刻意抛异常而不是返回一个"部分成功" —— 这几种情况下继续做的代价是删掉
54
+ 用户的真实数据,而那没有"部分"可言。
55
+ """
56
+
57
+
58
+ @dataclass
59
+ class SyncBatch:
60
+ """一批来源镜像数据,以及它自己声明的边界。
61
+
62
+ ``coverage_start`` / ``coverage_end`` 对 ``full`` 是**必填**:
63
+ "全量"永远是相对于某个范围说的,没有范围的全量删除删的是全部。
64
+ """
65
+
66
+ source: str
67
+ collection_kind: str
68
+ sync_id: str
69
+ snapshot_kind: str = INCREMENTAL
70
+ items: Sequence[Any] = field(default_factory=tuple)
71
+ coverage_start: datetime | None = None
72
+ coverage_end: datetime | None = None
73
+ cursor: str | None = None
74
+ attempted_at: datetime | None = None
75
+ completed_at: datetime | None = None
76
+ #: 非空 = 这一批**没有成功拿到**。见 ``SyncOutcome`` 的文档。
77
+ error_code: str | None = None
78
+
79
+
80
+ @dataclass
81
+ class SyncOutcome:
82
+ """这一轮做了什么。``failed`` 时 ``upserted`` / ``deleted`` 一定是 0。"""
83
+
84
+ upserted: int = 0
85
+ deleted: int = 0
86
+ failed: bool = False
87
+ #: 记下来的错误码,原样来自这一批。
88
+ error_code: str | None = None
89
+ #: 游标推进到哪了。失败时**保持原值**,不是 None —— 置空等于让下一轮
90
+ #: 从头拉一遍,而那对一个只是临时不可达的来源是纯粹的浪费。
91
+ cursor: str | None = None
92
+
93
+
94
+ def _existing_cursor(storage: StoragePort, ctx: IngestContext,
95
+ batch: SyncBatch) -> tuple[str | None, datetime | None]:
96
+ prior = storage.get_sync_state(
97
+ subject_id=ctx.subject_id, source=batch.source,
98
+ collection_kind=batch.collection_kind,
99
+ )
100
+ if prior is None:
101
+ return None, None
102
+ return prior.sync_cursor, prior.last_successful_sync_at
103
+
104
+
105
+ def sync_source_mirror(
106
+ storage: StoragePort, batch: SyncBatch, *, context: IngestContext,
107
+ ) -> SyncOutcome:
108
+ """把一批来源镜像数据落进去,并把同步状态推到该有的位置。
109
+
110
+ 顺序是固定的,而且**整个包在一个事务里**:写条目、按范围删、写同步状态
111
+ 要么全都成立、要么全都不成立。分开提交的话,"镜像里少一批条目、
112
+ 同步状态却说这轮成功了"就会出现,而下一轮增量同步不会去补 ——
113
+ 它以为上一轮是完整的。
114
+ """
115
+ kind = batch.snapshot_kind or INCREMENTAL
116
+ if kind not in (FULL, INCREMENTAL):
117
+ raise SyncContractError(
118
+ f"snapshot_kind={kind!r}:只有 {FULL!r} 和 {INCREMENTAL!r} 两种。"
119
+ f"不认识的种类不能当成增量放过去 —— 万一它的本意是全量,"
120
+ f"该删的没删,镜像会一直留着来源已经删掉的条目"
121
+ )
122
+ if kind == FULL and batch.error_code is None and (
123
+ batch.coverage_start is None or batch.coverage_end is None):
124
+ raise SyncContractError(
125
+ "全量同步必须给 coverage_start 和 coverage_end。"
126
+ "「全量」永远是相对于某个范围说的 —— 没有范围的全量删除,"
127
+ "删的是这个用户在这个来源下的全部条目,且不可逆"
128
+ )
129
+ if (batch.coverage_start is not None and batch.coverage_end is not None
130
+ and batch.coverage_start > batch.coverage_end):
131
+ raise SyncContractError(
132
+ f"coverage_start({batch.coverage_start}) 晚于 "
133
+ f"coverage_end({batch.coverage_end}):这个范围是空的,"
134
+ f"照它删会删掉范围外的一切或者什么都不删,两种都不该猜"
135
+ )
136
+
137
+ prior_cursor, prior_ok_at = _existing_cursor(storage, context, batch)
138
+ now = batch.completed_at or batch.attempted_at or context.received_at
139
+
140
+ # ── 失败的一批:记下来,但**什么都不动** ──────────────────────────
141
+ #
142
+ # 不 upsert(这一批的内容不可信)、不删(来源临时不可达不等于来源侧删光了)、
143
+ # 不推进游标(推进了那段数据就永远不会被再同步一次,而且没有任何地方
144
+ # 记得它缺过)、不动 last_successful_sync_at(否则"日历数据已过期"
145
+ # 这个判断永远为假)。
146
+ if batch.error_code is not None:
147
+ with storage.transaction():
148
+ storage.put_sync_state(SourceSyncState(
149
+ subject_id=context.subject_id,
150
+ source=batch.source,
151
+ collection_kind=batch.collection_kind,
152
+ sync_cursor=prior_cursor,
153
+ coverage_start=batch.coverage_start,
154
+ coverage_end=batch.coverage_end,
155
+ snapshot_kind=kind,
156
+ last_attempted_at=batch.attempted_at or now,
157
+ last_successful_sync_at=prior_ok_at,
158
+ last_error_code=batch.error_code,
159
+ ))
160
+ return SyncOutcome(failed=True, error_code=batch.error_code,
161
+ cursor=prior_cursor)
162
+
163
+ with storage.transaction():
164
+ upserted = _upsert(storage, batch, context)
165
+ deleted = 0
166
+ if kind == FULL:
167
+ # 只有全量才有资格删,而且只在它自己声明的范围内。
168
+ deleted = int(storage.apply_source_snapshot(
169
+ subject_id=context.subject_id,
170
+ source=batch.source,
171
+ collection_kind=batch.collection_kind,
172
+ sync_id=batch.sync_id,
173
+ coverage_start=batch.coverage_start,
174
+ coverage_end=batch.coverage_end,
175
+ snapshot_kind=kind,
176
+ ) or 0)
177
+ storage.put_sync_state(SourceSyncState(
178
+ subject_id=context.subject_id,
179
+ source=batch.source,
180
+ collection_kind=batch.collection_kind,
181
+ sync_cursor=batch.cursor if batch.cursor is not None else prior_cursor,
182
+ coverage_start=batch.coverage_start,
183
+ coverage_end=batch.coverage_end,
184
+ snapshot_kind=kind,
185
+ last_attempted_at=batch.attempted_at or now,
186
+ last_successful_sync_at=now,
187
+ # 成功了就把上一次的错误清掉 —— 留着的话,一次早就恢复的
188
+ # 故障会一直挂在状态里,看的人分不清是历史还是现在。
189
+ last_error_code=None,
190
+ ))
191
+ return SyncOutcome(upserted=upserted, deleted=deleted,
192
+ cursor=batch.cursor if batch.cursor is not None
193
+ else prior_cursor)
194
+
195
+
196
+ def _upsert(storage: StoragePort, batch: SyncBatch,
197
+ context: IngestContext) -> int:
198
+ items = list(batch.items)
199
+ if not items:
200
+ return 0
201
+ # 🔴 **这一批的条目必须打上这一批的 sync_id。**
202
+ #
203
+ # 全量收尾删的是「覆盖范围内、这轮没见到的」,判据就是这个字段。
204
+ # 不打的话,刚写进去的条目在同一个事务里被自己的快照收尾删掉 ——
205
+ # 一次"成功"的全量同步,结果是镜像空了。
206
+ # 这个由 kit 打,不指望宿主记得:忘了不报错,只是数据没了。
207
+ items = [replace(i, last_seen_sync_id=batch.sync_id) for i in items]
208
+ kinds = {type(i) for i in items}
209
+ if kinds == {CalendarEventMirror}:
210
+ storage.upsert_calendar_events(subject_id=context.subject_id,
211
+ events=items)
212
+ elif kinds == {ReminderItemMirror}:
213
+ storage.upsert_reminders(subject_id=context.subject_id, items=items)
214
+ else:
215
+ # 混着来说明宿主那边的翻译出了问题。挑一部分写进去、剩下的丢掉,
216
+ # 会得到一份"成功了"的半份镜像。
217
+ raise SyncContractError(
218
+ f"一批里混了 {sorted(k.__name__ for k in kinds)}:"
219
+ f"一次同步只处理一种集合,混着来会写出一份看起来成功的半份镜像"
220
+ )
221
+ return len(items)
222
+
223
+
224
+ __all__ = [
225
+ "CALENDAR", "REMINDERS", "FULL", "INCREMENTAL",
226
+ "SyncBatch", "SyncOutcome", "SyncContractError", "sync_source_mirror",
227
+ ]
@@ -0,0 +1,251 @@
1
+ """来源镜像同步的编排规则。
2
+
3
+ 外部审查(2026-09-02,P0-2):"只有 Storage 原语,没有标准同步工作流"。
4
+ 成立:写入侧有四个原语但没有和 ingest() 对等的入口,于是每个宿主自己拼
5
+ 「收数据 → upsert → 写状态 → 全量按范围删 → 处理失败」这一串。
6
+
7
+ 这个文件测的是**规则**,不是"函数能跑"。这条路上的每个坑错了都不报错,
8
+ 而且大多不可逆 —— 用户发现自己去年的日程凭空消失了,而系统一切正常。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from datetime import datetime, timedelta, timezone
13
+
14
+ import pytest
15
+
16
+ from perceptkit import IngestContext, PerceptionKit
17
+ from perceptkit.conformance import InMemoryStorage
18
+ from perceptkit.contracts.records import CalendarEventMirror, ReminderItemMirror
19
+ from perceptkit.processing.source_sync import (
20
+ FULL, INCREMENTAL, SyncBatch, SyncContractError, sync_source_mirror,
21
+ )
22
+
23
+ T0 = datetime(2026, 9, 2, 9, 0, tzinfo=timezone.utc)
24
+ CTX = IngestContext("u1", T0)
25
+
26
+
27
+ def _event(eid: str, at: datetime = T0) -> CalendarEventMirror:
28
+ return CalendarEventMirror(
29
+ subject_id="u1", source_account_id="a", source_calendar_id="c",
30
+ source_event_id=eid, event_fields={"title": eid, "start_at": at},
31
+ last_seen_sync_id="old",
32
+ )
33
+
34
+
35
+ def _batch(**over) -> SyncBatch:
36
+ base = dict(source="ios", collection_kind="calendar", sync_id="s1",
37
+ snapshot_kind=INCREMENTAL, items=[_event("e1")])
38
+ base.update(over)
39
+ return SyncBatch(**base)
40
+
41
+
42
+ def _state(storage):
43
+ return storage.get_sync_state(subject_id="u1", source="ios",
44
+ collection_kind="calendar")
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # 增量绝不许删
49
+ # ---------------------------------------------------------------------------
50
+
51
+ def test_an_incremental_batch_never_deletes_anything():
52
+ """拿一个局部窗口去删窗口外的数据 —— 用户发现自己去年的日程凭空消失了。"""
53
+ s = InMemoryStorage()
54
+ s.upsert_calendar_events(subject_id="u1", events=[_event("old-one")])
55
+ # ⚠️ 这一批**带着覆盖范围**。增量给范围是合法的(它说明这批数据来自哪一段),
56
+ # 但它仍然不许删。不给范围的话这条测试会因为错误的原因通过:
57
+ # 就算删除逻辑真的跑了,没有范围也删不掉任何东西。
58
+ out = sync_source_mirror(s, _batch(
59
+ items=[_event("e1")],
60
+ coverage_start=T0 - timedelta(days=1), coverage_end=T0 + timedelta(days=1),
61
+ ), context=CTX)
62
+ assert out.deleted == 0
63
+ left = {e.source_event_id for e in s.list_calendar_events(subject_id="u1", limit=50)}
64
+ assert "old-one" in left, "增量同步把范围内的旧条目删了"
65
+
66
+
67
+ def test_a_host_that_forgot_the_incremental_guard_is_still_safe():
68
+ """端口契约要求 ``snapshot_kind != full`` 时一条都不删,内存参考实现也照做了。
69
+
70
+ 但**这条不能指望每个宿主都实现对** —— 忘了就是删掉用户范围内的全部
71
+ 日程,不可逆。所以 kit 这一层压根不调用它。
72
+
73
+ 这里用一个"忘了那条守卫"的宿主来证明:kit 根本不会走到那个调用。
74
+ """
75
+ class _ForgetfulHost(InMemoryStorage):
76
+ called_with: list = []
77
+
78
+ def apply_source_snapshot(self, **kw):
79
+ _ForgetfulHost.called_with.append(kw["snapshot_kind"])
80
+ return super().apply_source_snapshot(**{**kw, "snapshot_kind": FULL})
81
+
82
+ _ForgetfulHost.called_with = []
83
+ s = _ForgetfulHost()
84
+ s.upsert_calendar_events(subject_id="u1", events=[_event("old-one")])
85
+ sync_source_mirror(s, _batch(
86
+ items=[_event("e1")],
87
+ coverage_start=T0 - timedelta(days=1), coverage_end=T0 + timedelta(days=1),
88
+ ), context=CTX)
89
+ assert _ForgetfulHost.called_with == [], (
90
+ "增量批次调到了快照收尾 —— 宿主只要少一条守卫就会删掉真实数据")
91
+ left = {e.source_event_id for e in s.list_calendar_events(subject_id="u1", limit=50)}
92
+ assert "old-one" in left
93
+
94
+
95
+ def test_a_full_batch_deletes_inside_its_coverage():
96
+ s = InMemoryStorage()
97
+ s.upsert_calendar_events(subject_id="u1", events=[_event("gone")])
98
+ out = sync_source_mirror(s, _batch(
99
+ snapshot_kind=FULL, items=[_event("e1")],
100
+ coverage_start=T0 - timedelta(days=1), coverage_end=T0 + timedelta(days=1),
101
+ ), context=CTX)
102
+ left = {e.source_event_id for e in s.list_calendar_events(subject_id="u1", limit=50)}
103
+ assert "gone" not in left and "e1" in left
104
+ assert out.deleted >= 1
105
+
106
+
107
+ def test_a_full_batch_without_coverage_is_refused():
108
+ """「全量」永远是相对于某个范围说的。没有范围的全量删除删的是全部。"""
109
+ s = InMemoryStorage()
110
+ with pytest.raises(SyncContractError, match="coverage"):
111
+ sync_source_mirror(s, _batch(snapshot_kind=FULL, coverage_start=None,
112
+ coverage_end=None), context=CTX)
113
+
114
+
115
+ def test_an_unknown_snapshot_kind_is_refused_not_treated_as_incremental():
116
+ """不认识的种类当成增量放过去:万一它的本意是全量,该删的没删,
117
+ 镜像会一直留着来源已经删掉的条目。"""
118
+ s = InMemoryStorage()
119
+ with pytest.raises(SyncContractError, match="snapshot_kind"):
120
+ sync_source_mirror(s, _batch(snapshot_kind="partial"), context=CTX)
121
+
122
+
123
+ def test_an_inverted_coverage_window_is_refused():
124
+ s = InMemoryStorage()
125
+ with pytest.raises(SyncContractError, match="coverage_start"):
126
+ sync_source_mirror(s, _batch(
127
+ snapshot_kind=FULL, coverage_start=T0 + timedelta(days=1),
128
+ coverage_end=T0 - timedelta(days=1)), context=CTX)
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # 失败的一批:记下来,但什么都不动
133
+ # ---------------------------------------------------------------------------
134
+
135
+ def test_a_failed_batch_changes_no_data():
136
+ """来源临时不可达 ≠ 来源侧删光了。"""
137
+ s = InMemoryStorage()
138
+ s.upsert_calendar_events(subject_id="u1", events=[_event("keep")])
139
+ out = sync_source_mirror(s, _batch(
140
+ snapshot_kind=FULL, error_code="transport_timeout",
141
+ items=[_event("e1")],
142
+ coverage_start=T0 - timedelta(days=1), coverage_end=T0 + timedelta(days=1),
143
+ ), context=CTX)
144
+ assert out.failed and out.upserted == 0 and out.deleted == 0
145
+ left = {e.source_event_id for e in s.list_calendar_events(subject_id="u1", limit=50)}
146
+ assert left == {"keep"}, "失败的一批把数据写进去了"
147
+
148
+
149
+ def test_a_failed_batch_does_not_advance_the_cursor():
150
+ """推进了那段数据就**永远**不会再被同步一次,而且没有任何地方记得它缺过。"""
151
+ s = InMemoryStorage()
152
+ sync_source_mirror(s, _batch(cursor="page-1"), context=CTX)
153
+ assert _state(s).sync_cursor == "page-1"
154
+
155
+ sync_source_mirror(s, _batch(cursor="page-2", error_code="http_503"),
156
+ context=CTX)
157
+ assert _state(s).sync_cursor == "page-1", "失败还把游标推到了 page-2"
158
+
159
+
160
+ def test_a_failed_batch_does_not_touch_last_successful_sync_at():
161
+ """否则「日历数据已过期」这个判断永远为假 —— 同步挂了三天,
162
+ 界面还在说这是最新完整的数据。"""
163
+ s = InMemoryStorage()
164
+ sync_source_mirror(s, _batch(), context=CTX)
165
+ ok_at = _state(s).last_successful_sync_at
166
+ assert ok_at is not None
167
+
168
+ later = IngestContext("u1", T0 + timedelta(days=3))
169
+ sync_source_mirror(s, _batch(error_code="auth_revoked"), context=later)
170
+ st = _state(s)
171
+ assert st.last_successful_sync_at == ok_at
172
+ assert st.last_error_code == "auth_revoked"
173
+ assert st.last_attempted_at is not None
174
+
175
+
176
+ def test_a_success_clears_a_stale_error_code():
177
+ """留着的话,一次早就恢复的故障会一直挂在状态里,看的人分不清
178
+ 是历史还是现在。"""
179
+ s = InMemoryStorage()
180
+ sync_source_mirror(s, _batch(error_code="http_503"), context=CTX)
181
+ assert _state(s).last_error_code == "http_503"
182
+ sync_source_mirror(s, _batch(), context=CTX)
183
+ assert _state(s).last_error_code is None
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # 三个动作在同一个事务里
188
+ # ---------------------------------------------------------------------------
189
+
190
+ def test_the_whole_round_is_one_transaction():
191
+ """分开提交的话,「镜像里少一批条目、同步状态却说这轮成功了」就会出现,
192
+ 而下一轮增量同步不会去补 —— 它以为上一轮是完整的。"""
193
+ s = InMemoryStorage()
194
+ before = s.transactions_opened
195
+ sync_source_mirror(s, _batch(
196
+ snapshot_kind=FULL, coverage_start=T0 - timedelta(days=1),
197
+ coverage_end=T0 + timedelta(days=1)), context=CTX)
198
+ assert s.transactions_opened == before + 1, "写条目/删/写状态没在同一个事务里"
199
+
200
+
201
+ def test_a_mixed_batch_is_refused():
202
+ """挑一部分写进去、剩下的丢掉,会得到一份"成功了"的半份镜像。"""
203
+ s = InMemoryStorage()
204
+ mixed = [_event("e1"), ReminderItemMirror(
205
+ subject_id="u1", source_account_id="a", source_list_id="l",
206
+ source_reminder_id="r1", reminder_fields={"title": "买牛奶"})]
207
+ with pytest.raises(SyncContractError, match="混"):
208
+ sync_source_mirror(s, _batch(items=mixed), context=CTX)
209
+
210
+
211
+ def test_reminders_go_down_the_reminder_path():
212
+ s = InMemoryStorage()
213
+ item = ReminderItemMirror(
214
+ subject_id="u1", source_account_id="a", source_list_id="l",
215
+ source_reminder_id="r1",
216
+ reminder_fields={"title": "买牛奶", "is_completed": False})
217
+ out = sync_source_mirror(s, SyncBatch(
218
+ source="ios", collection_kind="reminders", sync_id="s1", items=[item]),
219
+ context=CTX)
220
+ assert out.upserted == 1
221
+ assert [r.source_reminder_id
222
+ for r in s.list_reminders(subject_id="u1", limit=10)] == ["r1"]
223
+
224
+
225
+ def test_the_kit_exposes_it_next_to_ingest():
226
+ """和 ingest() 对等的入口 —— 这条是审查里点名要的。"""
227
+ s = InMemoryStorage()
228
+ kit = PerceptionKit(storage=s)
229
+ out = kit.sync_source_mirror(_batch(), context=CTX)
230
+ assert out.upserted == 1
231
+
232
+
233
+ def test_a_full_sync_does_not_delete_the_items_it_just_wrote():
234
+ """全量收尾删的是「覆盖范围内、这轮没见到的」,判据是 last_seen_sync_id。
235
+
236
+ 这批条目不打上这一批的 sync_id,刚写进去的就会在同一个事务里被自己的
237
+ 快照收尾删掉 —— 一次"成功"的全量同步,结果是镜像空了。
238
+
239
+ 这条是写这个文件时真踩到的:宿主传进来的条目带着上一轮的 sync_id,
240
+ orchestration 原样 upsert,然后 apply_source_snapshot 把它们全删了。
241
+ """
242
+ s = InMemoryStorage()
243
+ stale = [_event("e1"), _event("e2")] # helper 带的是 last_seen_sync_id="old"
244
+ assert all(e.last_seen_sync_id == "old" for e in stale)
245
+ out = sync_source_mirror(s, _batch(
246
+ snapshot_kind=FULL, sync_id="round-7", items=stale,
247
+ coverage_start=T0 - timedelta(days=1), coverage_end=T0 + timedelta(days=1),
248
+ ), context=CTX)
249
+ left = {e.source_event_id for e in s.list_calendar_events(subject_id="u1", limit=50)}
250
+ assert left == {"e1", "e2"}, f"全量同步把自己刚写的条目删了,剩下 {left}"
251
+ assert out.upserted == 2
@@ -16,7 +16,7 @@ name = "exceptiongroup"
16
16
  version = "1.3.1"
17
17
  source = { registry = "https://pypi.org/simple" }
18
18
  dependencies = [
19
- { name = "typing-extensions" },
19
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
20
20
  ]
21
21
  sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
22
22
  wheels = [
@@ -43,7 +43,7 @@ wheels = [
43
43
 
44
44
  [[package]]
45
45
  name = "perceptkit"
46
- version = "0.2.7"
46
+ version = "0.2.8"
47
47
  source = { editable = "." }
48
48
 
49
49
  [package.optional-dependencies]
File without changes
File without changes
File without changes