数据截至 (上游 commit 2689884a6257)
启动器与统一 CLI
这一章讲什么:
pip install openadapt装到的那个包,今天已经不做任何自动化工作。它是个转发壳。壳看起来简单,但这个壳解决了三个非常具体的工程问题,值得单独讲。
1. 它要解决的小问题
一个项目拆成多个 PyPI 包之后,会立刻遇到三件麻烦事:
- 命令入口分裂。 用户记不住
openadapt-flow replay和openadapt-capture start是两个不同的可执行文件。 - 壳会拖慢引擎。 如果壳把引擎的每个选项都显式包装一遍,引擎新增一个
--rdp-host就得等壳发版。 - 可选依赖会污染基础安装。 训练要 PyTorch,录制要 PyObjC,大部分用户一个都不需要。
下面三节分别对应这三个问题的解法。
2. 解法一:未知命令原样转发(passthrough)
思路
先想清楚一件事:壳包装得越细,壳就越容易过时。
所以这里的取舍是——连动词都不包装。flow 是一个单命令,把包括第一个动词在内的所有参数原样交给引擎;click 层只保留 quickstart/deploy/connect 等壳自己的命令。
图示
用户敲:openadapt flow replay bundle --backend rdp --rdp-host 10.0.0.7
│
▼
flow 单命令(@main.command("flow"))
│ ignore_unknown_options=True
│ allow_extra_args=True
│ help_option_names=[] ← 连 --help 都交给引擎
▼
_run_flow(["replay", "bundle", ...全部原始参数])
真实实现
flow 命令本体在 openadapt/cli.py:392-409。三个 context_settings 是关键(:394-399):ignore_unknown_options 让引擎的新选项不会被 click 拒绝,allow_extra_args 收下全部位置参数,help_option_names: [] 让 --help 也透传给引擎,于是帮助文本永远是引擎的真话;收齐后 _run_flow(list(command_ctx.args))(:409)。
设计意图直接写在 docstring 里(cli.py:403-407):"Every argument passes to openadapt-flow unchanged"——launcher 的命令清单、帮助、选项、校验和退出码都与所装引擎版本完全一致。早先版本曾给 record/replay 写显式选项、并靠动态 passthrough 组兜底,结果藏掉了引擎的 backend 选项、并且落后于引擎,所以改成现在的全透传(旧的那张 _FLOW_PASSTHROUGH_COMMANDS 描述表与 _FlowPassthroughGroup 已一并删除)。
调用的两层拆分
# 示意,非源码:为什么要拆成两个函数
def _invoke_flow(argv): # 只负责调用,返回退出码
from openadapt_flow.__main__ import main as flow_main
return int(flow_main(argv))
def _run_flow(argv): # 负责把退出码变成进程退出
sys.exit(_invoke_flow(argv))
真实实现在 openadapt/cli.py:86-108(_invoke_flow、_run_flow)。拆开的好处在 quickstart 里立刻体现:它要在调用前后改环境变量、调用后还要打印后续提示,所以只能用返回退出码的那个版本,不能用会直接 sys.exit 的那个。
重点看这里: 引擎是进程内调用的(
from openadapt_flow.__main__ import main),不是subprocess。所以退出码要手动int()转换并sys.exit,否则会静默变成 0。
一个细节:引擎没装也不能崩
_invoke_flow 把 import 放在函数体里并捕获 ImportError(openadapt/cli.py:88-94),打印三行安装提示后返回 1。因此 openadapt flow --help 在引擎缺失时依然可用——这是把「壳的帮助」和「引擎的存在」解耦。
3. 解法二:模块级 __getattr__ 懒加载
思路
from openadapt import Recorder 应该在用到的时候才去 import openadapt_capture,而不是在 import openadapt 时。
Python 3.7+ 支持模块级 __getattr__:属性查不到时才调用它。openadapt/__init__.py:21-110 正是用这个做的一张「名字 → 来源包」路由表。
分组与降级策略
下面七组就是路由表的全部内容(按源码里的先后顺序):
| 名字组 | 来源包 | 位置 | 缺失时的行为 |
|---|---|---|---|
Capture、Recorder、Action… | openadapt_capture | :24-41 | 直接抛原生 ImportError |
BenchmarkAdapter、ApiAgent… | openadapt_evals | :44-57 | 直接抛原生 ImportError |
PageBuilder、HTMLBuilder | openadapt_viewer | :59-63 | 直接抛原生 ImportError |
QwenVLAdapter | openadapt_ml | :66-69 | 直接抛原生 ImportError |
ElementLocator、OmniParserClient | openadapt_grounding | :72-84 | 捕获后换成带安装命令的 ImportError |
MultimodalDemoRetriever | openadapt_retrieval | :87-96 | 同上 |
DemoLibrary | openadapt_evals | :99-108 | 同上 |
有意思的是这张表不统一:标成 optional 的后三组会把 ImportError 重写成 "... requires openadapt-grounding. Install with: pip install openadapt[grounding]"(openadapt/__init__.py:80-84),前四组不重写。代码里看不出为什么这四组不给同样的提示——从注释看,ml 那组的理由是「heavy - only import if explicitly requested」(openadapt/__init__.py:65)。
一个容易忽略的坑:import 就有副作用
version 命令刻意不 import 兄弟包,而是读分发元数据(openadapt/cli.py:903-908),注释给了原因:
导入
openadapt-capture会在 import 时截一张屏,在 CI 这种无头环境里直接崩。
同样的理由,doctor 用 importlib.util.find_spec 判断包在不在(openadapt/cli.py:1003-1006)——find_spec 只查找,不执行包代码。
这是可以直接抄走的一条经验: 探测「某个包装没装」永远用
find_spec,不要用import。