Extending
A how-to for consumers and third parties — add a seam implementation, wire a real backend behind an optional extra, or bind your own conformance invariants without touching corespine.
本文是操作指南:第三方包如何在不改 corespine 一行代码的前提下,给某条缝补一个实现、 接一个真实后端、绑自己的不变量。机制源码见
seam/registry.py与conformance/harness.py;宪章见CLAUDE.md,增长判据见 roadmap,现状见 prd。
corespine 只提供机制,扩展点全在缝上,四种方式互不耦合:
| 扩展方式 | 解决什么 | 关键 API | 章节 |
|---|---|---|---|
| entry-point 扩展一条缝 | 第三方装包即被 make 发现,无需改核心 | Registry.make / group corespine.<seam> | 一 |
| 可选 extra + 延迟 import | 真实后端 SDK 只在选用时才 import,缺依赖给友好提示 | lazy_extra_import / [project.optional-dependencies] | 二 |
| 给一条缝绑 conformance 不变量 | app 用自己的不变量逮住坏实现 | InvariantPack / ConformanceSuite | 三 |
| 用 trace 缝做可观测 + 接导出后端 | 步骤发非敏感元数据;经 TraceExporter 扇出,后端(OTel 等)走 app/contrib 侧 | TraceSink / TraceExporter / InProcessPrivacyTraceSink / FORBIDDEN_KEYS | 四 |
一、用 entry-point 扩展一条缝
每条缝是一个 Registry("<seam>") 实例;make(spec) 找不到内置名时,回落到
importlib.metadata 的 entry-point 自动发现,group 命名固定为 corespine.<seam>。
于是第三方只需在自己的 pyproject.toml 声明一个 entry point,装包即扩展,corespine
核心一行不改。
第三方包的 pyproject 片段
假设某条缝叫 vector_store,你的包 myadapter 想注册一个名为 pgvector 的实现:
# myadapter/pyproject.toml
[project.entry-points."corespine.vector_store"]
# 名字(左) -> "模块:工厂可调用对象"(右);工厂签名为 (**kwargs) -> 实现实例
pgvector = "myadapter.pg:make_pgvector"# myadapter/pg.py
def make_pgvector(**kwargs):
"""工厂:返回一个实现该缝 Protocol 的实例(此处省略真实细节)。"""
return PgVectorStore(**kwargs)装包后的调用示例
from corespine import Registry
# 同一条缝名 "vector_store" 必须与 entry-point group 后缀一致。
registry: Registry = Registry("vector_store")
# 内置名找不到 → 自动扫 group "corespine.vector_store" 发现 myadapter 的 pgvector。
store = registry.make("pgvector", dsn="postgresql://...")
# 解析大小写 / 连字符 / 留白不敏感:"PG-Vector" / " pgvector " 都解析到同一项。
registry.names() # 列出【内置 + entry-point 发现】的全部可用名(字典序、去重)要点:
- group 名必须是
corespine.<seam>,<seam>与Registry("<seam>")的入参完全一致; - 内置注册优先于 entry-point:同名时内置胜出(便于在测试里覆盖);
- 发现是延迟的:只在
make/names解析时才扫,不在 import 期付出代价; - 未知 spec 抛
ValueError,并列清当前全部可用名(绝不让人猜)。
已出货的真实范例: 0.2.0 的
blob缝就是这套模式的落地——BLOB_REGISTRY = Registry("blob")内置memory/filesystem两个离线默认,真实后端(S3 / MinIO)经 groupcorespine.blob的 entry-point +lazy_extra_import延迟接入,corespine 的dependencies仍为空。第三方接自己的缝时 照抄这个形状即可。
二、可选 extra 命名约定
薄核宪章(ADR 0001 D5):核心 dependencies 永远为空,默认路径零重依赖。真实后端的
SDK(Redis / OpenAI / …)由各 app 在自己的缝里经可选 extra 声明,并用
lazy_extra_import 延迟 import —— 选用该 adapter
时才 import,没装就给出可直接照做的安装指引。
extra 命名约定
| 约定 | 示例 | 说明 |
|---|---|---|
| 一个真实后端 → 一个 extra,以后端命名 | [redis] / [openai] | 名字即"装哪个后端",直观可猜 |
| extra 内只放该后端的 SDK | redis = ["redis>=5"] | 不夹带无关依赖,装得最小 |
lazy_extra_import 的 extra= 与之同名 | extra="redis" | 缺依赖时提示 pip install <pkg>[redis] 即可修 |
# 你的包 pyproject.toml:真实后端依赖声明为可选 extra(以后端命名)
[project.optional-dependencies]
redis = ["redis>=5"]
openai = ["openai>=1.0"]lazy_extra_import 用法
lazy_extra_import(module, *, pkg, extra) 把裸 ImportError 翻译成
pip install <pkg>[<extra>] 的友好提示:
from corespine import lazy_extra_import
def make_redis_queue(**kwargs):
# 只在真正构造该 adapter 时才 import;没装 redis 不影响核心离线默认路径。
redis = lazy_extra_import("redis", pkg="myadapter", extra="redis")
client = redis.Redis(**kwargs)
return RedisQueue(client)未装 redis 时调用 make_redis_queue(...) 会抛:
ImportError: 缺少可选依赖 'redis':请先 `pip install myadapter[redis]` 再重试。—— 而不是让调用方对着 ModuleNotFoundError: No module named 'redis' 自己猜该装哪个 extra。
三、给一条缝绑 conformance 不变量
corespine 的 conformance 是机制,非保证:harness 只负责"跑 实现 × 不变量 的笛卡尔积 +
报告哪个格子坏了",具体不变量由各 app 自己绑(ADR 0001 D6)。app 把自己的
InvariantPack 喂进
ConformanceSuite 即可。
最小骨架(完整可跑范例见 examples/conformance_usage.py):
from corespine import ConformanceSuite, InvariantPack
# 1) 实现注册表:名字 -> 无参工厂(每格各新建实例,杜绝实现间状态串味)
impls = {"counter": Counter, "broken": BrokenCounter}
# 2) app 自己的不变量包(corespine 核心不含任何具体不变量)
pack = (
InvariantPack("counter-contract")
.add("first-add-returns-n", lambda c: assert_eq(c.add(3), 3))
.add("accumulates", lambda c: assert_eq((c.add(2), c.add(5)), (2, 7)))
)
# 3) 绑成笛卡尔积,逐格跑
suite = ConformanceSuite(impls, pack)
for impl, invariant in suite.cases():
suite.check(impl, invariant) # 失败即抛,定位到具体格子
# 或:suite.run() 收集全部结果(不抛);suite.passed() 便捷判全过要点:
- 不变量 =
(实现实例) -> None,通过则正常返回、违反则抛异常;只验外部可观测行为; - 每个格子都新建实例(工厂无参),杜绝实现间状态串味;
cases()返回(实现名, 不变量名)列表,可直接喂pytest.mark.parametrize;ids()给对齐的可读 id(形如impl/invariant);- 这正是"敢放手让第三方填广度、却让脊柱不变量烂不掉"的落点:没过 conformance 的实现直接 CI 红,而非生产事故。
四、用 trace 缝做可观测
trace 缝(机制源码 observability/trace.py)只给三样
机制:TraceSink 协议(出口的最小结构面 emit(code, **fields))、InProcessPrivacyTraceSink
默认实现(隐私 by construction:载荷命中 FORBIDDEN_KEYS 即抛 TraceError、绝不记录)、以及
FORBIDDEN_KEYS 这份禁词键集合。具体记什么 code、绑什么不变量,由各 app 自己定。
完整可跑范例见 examples/trace_seam_usage.py,演示两种真实消费形态:
形态 1:隐私闸门(把默认 sink 包成自家薄封装)
把 InProcessPrivacyTraceSink 当作落盘前的强制隐私闸门:每条 trace 先过禁词键校验,过闸后才
落自家渠道(日志 / DB)。命中正文字段即抛,正文绝不外泄。
from corespine import InProcessPrivacyTraceSink, TraceError
class PrivacyGatedTrace:
def __init__(self) -> None:
self._gate = InProcessPrivacyTraceSink()
def emit_trace(self, code: str, **fields: object) -> None:
self._gate.emit(code, **fields) # 命中 FORBIDDEN_KEYS 直接抛,不会落到下游
... # 过闸后才落自家日志 / DB形态 2:注入点(以 TraceSink 形参接收任意 sink)
让步骤/方法以 trace: TraceSink | None 形参接收 sink,关键处只 emit(code, **非敏感字段);
塞哪个 sink 由 host 决定。注意只发长度 / 计数 / 耗时 / 标志,绝不发正文。
from corespine import TraceSink
def run_step(task: str, *, trace: TraceSink | None = None) -> str:
output = do_work(task)
if trace is not None:
trace.emit("step", task_chars=len(task), output_chars=len(output)) # 只发长度
return output接真实导出后端(OTel 等):经 TraceExporter 扇出,一律走 app/contrib 侧
0.2.0 起,把 trace 扇出到进程外有了专用缝:TraceExporter 协议(export(event: TraceEvent) -> None)
- 进程内默认
InProcessTraceExporter。InProcessPrivacyTraceSink可选挂 0..N 个 exporter (构造传exporters=[...]或事后add_exporter(...)),emit 的合法事件在隐私校验之后逐个扇出。
隐私不变量(不可放松): exporter 只会收到已过
FORBIDDEN_KEYS校验的TraceEvent——受限 正文在emit的校验阶段就被TraceError挡下,根本到不了任何 exporter。故导出面与本地记录面 等宽,天然只承载 code / 计数 / 耗时。挂 exporter 不会让隐私约定漏气。
from corespine import InProcessPrivacyTraceSink, InProcessTraceExporter, TraceExporter, lazy_extra_import
class OTelTraceExporter: # 在【你的包】里,不在 corespine 核心
def __init__(self) -> None:
# OTel SDK 经你自己的可选 extra 声明,延迟到真正构造时才 import。
self._otel = lazy_extra_import("opentelemetry.trace", pkg="myadapter", extra="otel")
...
def export(self, event) -> None: # 只会收到已过隐私校验的 TraceEvent(无需再自查禁词键)
... # 建 span / 记 metric:event.code / event.fields(只含元数据)
# host 组装:合法事件先本地记录、再扇出到你的 OTel exporter
sink = InProcessPrivacyTraceSink(exporters=[OTelTraceExporter()])
# 或注入点里的 sink 也能挂:sink.add_exporter(OTelTraceExporter())铁律(ADR 0001 D5 / 宪章): corespine 的
dependencies永远为空,核心绝不 import 任何 导出后端 SDK(OTel / 日志聚合 / APM)。核心只带TraceExporter协议 +InProcessTraceExporter进程内收集器;真实导出后端只在各 app 的可选 extra 或 contrib 里实现,默认路径恒为离线确定性。
要点:
- trace 只发非敏感元数据(code / 计数 / 耗时 / 标志);禁词键集合见
FORBIDDEN_KEYS; - 扇出经
TraceExporter缝,严格发生在隐私校验之后——exporter 天然收不到正文,无需自己再兜隐私; - 默认 exporter(
InProcessTraceExporter)离线确定性、零依赖;真实导出后端永远在 app/contrib 侧, 核心不依赖任何 SDK; - 若你更想在落盘前就拦截(而非扇出),仍可用形态 1 的隐私闸门模式;两者互补。