插件协议 v1:给 myastro 加数据源¶
状态:注册契约(批次 1)、自动发现(2a)与状态可见性(2b)均已落地。装好插件包即自动生效, 不需要用户写
import;myastro doctor会报告插件加载状态与失败原因,sources()标注来源。 契约变更记录见docs/zh/dev/contract-changelog.md;变更流程见docs/zh/dev/change-policy.md。
1. 目标¶
第三方不改内核就能加源:内核(src/myastro/io/db.py、src/myastro/io/jobs/、
账本 schema、层边界)对插件完全封闭,插件只通过两个公开 API 接入:
import myastro as ma
ma.register_task("mysurvey_fetch", my_fetch_fn) # ① 任务函数(按名寻址)
ma.register_source(ma.SourceDescriptor(...)) # ② 源描述符(表单/校验/路由)
为什么任务必须按名注册:JobSpec 只存任务名(不存闭包),重试与跨进程恢复都靠它。
传闭包会让作业无法跨进程恢复。
2. 最小可用插件¶
"""my_survey.py —— 一个离线可测的示例插件。
只用公开 API:`ma.FetchResult` / `ma.RunContext` / `ma.write_parquet` /
`ma.register_task` / `ma.register_source` / `ma.FieldSpec` / `ma.SourceDescriptor`。
**
不要**导入 `myastro.io.*`(内核私有,随时可能变)。
"""
import myastro as ma
def my_fetch_fn(ctx: ma.RunContext) -> ma.FetchResult:
"""ctx: RunContext(source, params, dest_dir, cfg)。落盘必须写在 ctx.dest_dir 内。"""
import pandas as pd
dest = ctx.dest_dir / f"{ctx.params['target']}.parquet"
ma.write_parquet(pd.DataFrame({"x": [1, 2, 3]}), dest) # 走统一落盘(见 processing §8.8)
return ma.FetchResult(paths=[dest], dataset_id=0, meta={"n": 3})
ma.register_task("mysurvey_fetch", my_fetch_fn)
ma.register_source(ma.SourceDescriptor(
name="mysurvey",
desc="我的巡天",
product="parquet",
status="ok",
fields=[ma.FieldSpec("target", label="目标名", pattern=r"^[A-Za-z0-9 _-]{1,32}$")],
task="mysurvey_fetch",
example_params={"target": "M31"},
))
注册后立即可用:myastro fetch mysurvey --target M31、TUI 里 F1 → 选「我的巡天」、
myastro sources 列表里出现该源、数据照常入账本并可 myastro search 检索。
3. 校验规则(注册即 fail-fast)¶
| 规则 | 说明 |
|---|---|
源名 ^[a-z][a-z0-9-]{0,31}$ |
小写字母开头,可含数字/连字符 |
desc / product 非空 |
空描述会让 TUI 与 sources 无从显示 |
status ∈ {ok, degraded, unreachable} |
与内置源的封闭词汇表一致 |
api_version == PLUGIN_API_VERSION |
不匹配拒绝注册(不崩) |
字段名唯一且匹配 ^[a-z][a-z0-9_]{0,31}$ |
字段名同时是 CLI 参数名与 TUI 控件 id |
pattern 可编译;index 必须存在 |
索引名来自 src/myastro/io/index.py 的 INDEXES |
必须且只能声明 task 或 url_key |
两者都没有 = 无法构建作业;都有 = 路由歧义 |
task 必须已在 TASKS 里;url_key 必须在 URL_SOURCES 里 |
先 register_task 再 register_source |
url_key 源的字符串字段必须有 pattern/name_like/choices |
参数会进 URL 与文件名 → 防路径穿越 |
example_params 必填且必须通过自己的校验 |
TUI 的 F2 示例依赖它;这条能挡住大多数劣质插件 |
requires 里的模块名合法,且声明了就必须给 install_hint |
doctor 用它告诉用户怎么装 |
probe_url 必须是 http(s) |
health 探针 |
内置源名不可覆盖(连 replace=True 也不行) |
内置名保留给本体 |
失败一律抛 ParamError(退出码 2),不留部分状态;错误信息里带可用取值,便于直接改。
4. 禁止事项¶
- 不要碰账本 schema、作业生命周期、层边界——那些是内核;
- 落盘只能写在
ctx.dest_dir内(内核有_assert_paths_inside_data_root兜底, 越界会被拒,且不会留下半条记录); - 不要注册与内置任务同名的任务;不要试图替换内置源;
- 目前不能新增
url_key(URL_SOURCES不可扩展)——需要 aria2 批量下载能力的插件, 请在任务内部自行发起请求(或先提 issue 讨论扩展点)。
5. 版本与信任¶
- 协议版本:
myastro.descriptors.PLUGIN_API_VERSION。插件在描述符里声明api_version; 不匹配时注册被拒(而不是运行到一半崩)。 - 信任模型:安装第三方插件 = 信任其代码(等同
pip install)。myastro 不做沙箱, 也不会上传你的任何数据。见SECURITY.md。
6. 插件不得扰动本体守卫¶
契约快照(docs/contract-snapshot.json)与守卫只绑内置源与内置任务
(BUILTIN_SOURCES / BUILTIN_TASKS):装了插件之后 pytest 与
myastro contract-snapshot --check 必须仍然全绿。
这条由 tests/kernel/test_plugin_contract.py::test_plugin_does_not_perturb_guards_or_snapshot
守着——它同时保证快照里的 fetch <源> 子命令只收内置源。
7. 参考实现与测试¶
- 内置 10 个源就是协议的参考实现(
src/myastro/descriptors.py的REGISTRY); - 插件契约测试:
tests/kernel/test_plugin_contract.py(反例 20 条 + 离线端到端); - 扩展点守卫:
tests/guards/test_descriptor_contract.py::test_register_source_extension_point。
8. 后续批次¶
| 批次 | 内容 | 状态 |
|---|---|---|
| 2a(L3) | 自动发现:entry-points + MYASTRO_SOURCES + 子进程预探测隔离 + 惰性触发 + 恢复兜底 |
已完成 |
| 2b | doctor 展示插件状态与失败原因、sources() 标注来源 |
已完成 |
| 3 | 仓库内示例插件包 + CI 安装并端到端取一次 | 已完成 |
批次 2a:自动发现(0.5.0 起)¶
装好即用:插件包在 pyproject.toml 里声明 entry point 即可,用户不需要写任何 import:
[project.entry-points."myastro.sources"]
mysurvey = "my_package.mysurvey" # 值是模块名;也可写 `模块:注册函数`(可调用则被调用)
本机开发(未打包)用环境变量:
export MYASTRO_SOURCES=my_plugin,another_plugin:register
设计要点(细节见 src/myastro/io/plugins.py 的模块文档):
- 惰性:
import myastro不触发发现;由需要源清单的入口触发一次并缓存 (sources()、按源名查表、cli.main()建解析器之前、以及 runner 的任务解析兜底)。 - 隔离:先在本进程之外预探测(子进程
import+ 超时),import 阶段挂死/崩溃的插件 被记为错误并跳过,不影响主进程(子进程按结构化标记回报异常类型,诊断精度不减)。 - 恢复兜底:作业的具名任务若在当前进程的表里找不到,会触发一次发现再解析—— 作业可能是另一个进程创建的。
- 错误可见:失败插件记一条结构化记录(模块名/异常类型/摘要/建议),由
doctor与sources()呈现(批次 2b 接入展示层;现在可用myastro.io.plugins.errors()读取)。
批次 2b:状态可见性(0.5.1 起)¶
myastro doctor新增「插件(可选)」检查:列出已加载插件;每个失败插件单独一条 WARN, 带异常类型、原因与修复建议(例如协议版本不符会指向本页)。挂死插件报ProbeTimeout, 但 doctor 本身照常返回——「失败必须可见」与「失败不能拖垮核心」两件事同时成立。myastro.sources()每一项新增origin字段:
| 取值 | 含义 |
|---|---|
builtin |
myastro 内置源 |
plugin:<模块> |
由该插件包提供(发现时按「导入前后新增的注册表键」归属) |
runtime |
插件机制之外显式 register_source 注册的源(例如 notebook 里临时加的) |
信任模型不变:安装第三方插件 = 信任其代码(预探测只挡"挂死/导入即崩",
不是沙箱)。见 SECURITY.md。
批次 3:可运行的参考示例¶
examples/myastro-source-example/(demo 源:离线合成光变)——它就是 §2 那段示例的完整可安装版本:
pip install -e examples/myastro-source-example
python -c "import myastro_example_source" && myastro fetch demo --target M31
它同时是约束的活演示:只用公开 API、任务按名注册、进文件名的字段声明 pattern、
落盘走 ma.write_parquet 且写在 ctx.dest_dir 内。
守候:tests/kernel/test_example_plugin.py(本机导入即注册 → 真取一次 →
断言落盘/入账本/可检索;并断言示例不导入 myastro.io.*,AST 级检查、变异验证可红),
以及 CI 的 build 任务里同名步骤(安装示例包后按文档写法跑一遍)。