跳到主要内容

数据截至 (上游 commit 2689884a6257)

启动器与统一 CLI

这一章讲什么: pip install openadapt 装到的那个包,今天已经不做任何自动化工作。它是个转发壳。壳看起来简单,但这个壳解决了三个非常具体的工程问题,值得单独讲。


1. 它要解决的小问题

一个项目拆成多个 PyPI 包之后,会立刻遇到三件麻烦事:

  1. 命令入口分裂。 用户记不住 openadapt-flow replayopenadapt-capture start 是两个不同的可执行文件。
  2. 壳会拖慢引擎。 如果壳把引擎的每个选项都显式包装一遍,引擎新增一个 --rdp-host 就得等壳发版。
  3. 可选依赖会污染基础安装。 训练要 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 正是用这个做的一张「名字 → 来源包」路由表。

分组与降级策略

下面七组就是路由表的全部内容(按源码里的先后顺序):

名字组来源包位置缺失时的行为
CaptureRecorderActionopenadapt_capture:24-41直接抛原生 ImportError
BenchmarkAdapterApiAgentopenadapt_evals:44-57直接抛原生 ImportError
PageBuilderHTMLBuilderopenadapt_viewer:59-63直接抛原生 ImportError
QwenVLAdapteropenadapt_ml:66-69直接抛原生 ImportError
ElementLocatorOmniParserClientopenadapt_grounding:72-84捕获后换成带安装命令的 ImportError
MultimodalDemoRetrieveropenadapt_retrieval:87-96同上
DemoLibraryopenadapt_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 这种无头环境里直接崩。

同样的理由,doctorimportlib.util.find_spec 判断包在不在(openadapt/cli.py:1003-1006)——find_spec 只查找,不执行包代码。

这是可以直接抄走的一条经验: 探测「某个包装没装」永远用 find_spec,不要用 import


4. 解法三:AST 静态检查跨包导入

它要解决的小问题

懒加载有个天然缺陷:函数体里的 import 在普通的 import 测试里永远不会执行。于是 cli.py 里写了 from openadapt_ml.scripts.train import main,而对面根本没有这个符号,测试全绿,用户一跑就炸。

tests/test_import_integrity.py:1-11 的文件头把这个 bug 类别记成了「#999 class of bug」,并点名了两个不存在的符号:serve_dashboardtrain_main

思路

既然运行时抓不到,就不运行——直接解析 AST。

AST(Abstract Syntax Tree,抽象语法树)是源码被解析成的结构化树:能看清「这一行从哪个模块 import 了哪个名字」,但不执行任何代码。Python 标准库的 ast 模块就干这件事。

对本包每个 .py:
├─ ast.parse
├─ 找出所有 ImportFrom 节点
├─ 目标模块属于「被检查的包前缀」吗?(本包 + 6 个兄弟包)
│ └─ 不属于 → 跳过
├─ 解析目标模块的 AST,收集它顶层定义了哪些名字
│ └─ 目标模块自己有 __getattr__ → 放行(动态导出无法静态判定)
└─ 被 import 的名字不在里面 → 记一条 problem

那 6 个兄弟包写死在 EXTERNAL_PACKAGES(tests/test_import_integrity.py:28-35):openadapt_mlopenadapt_captureopenadapt_evalsopenadapt_vieweropenadapt_groundingopenadapt_retrieval——正好覆盖上一节那张路由表的全部来源。

真实实现

  • _collect_defined(tests/test_import_integrity.py:77-115)递归走 If/Try/With 的各个分支收集顶层名字,并把 __getattr__ 的存在标记为 dynamic
  • test_no_phantom_imports(:171-210)是主检查。
  • test_no_phantom_kwargs(:232-275)更进一步:检查调用时传的关键字参数是否在对面函数的签名里。带装饰器或有 **kwargs 的函数会被跳过(:223-224),因为静态判不准。

一条关于「假绿」的纪律

跨包检查在兄弟包没装时会优雅跳过。但那样 CI 也会「全绿而什么都没检查」。所以有一条专门盯着这件事的测试:test_external_packages_installed_in_ci(tests/test_import_integrity.py:156-168):

# 真实源码节选,tests/test_import_integrity.py:162-168
if not os.environ.get("CI"):
pytest.skip("sibling install only enforced in CI")
missing = [p for p in EXTERNAL_PACKAGES if importlib.util.find_spec(p) is None]
assert not missing

本地允许跳过,CI 里必须全装。注释写的是「#999 的元教训:一个什么都不验证的绿勾比没有检查更糟」。这句话在 scripts/check_source_boundary.py:27-29.github/release-health.json 里各出现了一次同义表述——是这个项目的一条贯穿性原则,06 章讲发布治理时还会遇到,那里会直接回指上面这个函数名。


5. 另外两个值得看的命令

quickstart:一次性教程,且不覆盖已有目录

quickstart(openadapt/cli.py:132-208)本身也只是在拼引擎的 tutorial 子命令 argv,但它做了三件壳该做的事:

  1. 先检查输出目录存在与否再动手(:196-199),存在就报 UsageError,绝不覆盖。
  2. 临时把 OPENADAPT_FLOW_SCRUB 设成 off,并在 finally 里精确恢复(:216-226)。恢复逻辑区分「原本没设」和「原本设的是 auto」,前者 pop 后者写回——这是很多人会写错的地方。
  3. 引擎失败时不删已产出的中间产物(:227-231),异常信息里直接告诉你产物还在哪。

deploy:只做预检,不启动任何东西

deploy(openadapt/cli.py:240-389)是一个「preflight + 指路」命令。它的自我约束写在 docstring 里:不创建第二个运行时、不记录任何 secret 值、不把不完整的预检当成部署成功。

最值得学的是 secret 的处理方式:

# 真实源码,openadapt/cli.py:264
_SECRET_REFERENCE = re.compile(r"^(?:env:[A-Z][A-Z0-9_]*|keychain:[^/\s]+/[^/\s]+)$")

--secret-ref 只接受引用(env:NAMEkeychain:service/item),不接受值。不匹配时打印的是 "[INVALID] rejected secret reference (value hidden)"(:323)——连拒绝信息里都不回显用户传进来的东西,避免把密钥写进日志。

引擎版本范围也是硬检查:_supported_flow_version(:268-276)用正则解析出三元组,和 (1,29,0) <= v < (2,0,0) 比较,不在范围内报 [UNSUPPORTED] 而不是 [MISSING]——两种失败模式给出的修复动作不一样。


6. 版本号的单一来源

# 真实源码节选,openadapt/version.py:13-19
try:
__version__ = _dist_version("openadapt")
except PackageNotFoundError:
__version__ = "0.0.0+unknown"

版本从已安装分发的元数据读,不在代码里写死。pyproject.toml 是唯一真源,semantic-release 只改那一处。源码树里没装过的情况报一个明显假的哨兵值 0.0.0+unknown,而不是一个看起来像真的旧数字——这个选择在 openadapt/version.py:15-18 的注释里说明了。


7. 代码地图

主题文件路径符号名
flow 排在帮助最前openadapt/cli.py_FlowFirstGroup
未知命令原样转发openadapt/cli.py_FlowPassthroughGroup_FLOW_PASSTHROUGH_COMMANDS
引擎调用与退出码openadapt/cli.py_invoke_flow_run_flow
本地教程与产物保留openadapt/cli.pyquickstart
部署预检与 secret 引用openadapt/cli.pydeploy_SECRET_REFERENCE_supported_flow_version
环境体检openadapt/cli.pydoctorversion
可选包懒加载openadapt/__init__.py__getattr__
版本单一来源openadapt/version.py__version__
幻觉导入静态检查tests/test_import_integrity.pytest_no_phantom_importstest_no_phantom_kwargs_collect_defined
CI 必须装齐兄弟包(防假绿)tests/test_import_integrity.pytest_external_packages_installed_in_ciEXTERNAL_PACKAGES