数据截至 (上游 commit 5359534c6f00)
第 5 章 · 打包、分发与自动发现
本章讲「环境怎么从你的机器走到别人的训练循 环里」。涉及
src/openenv/cli/、src/openenv/core/containers/和src/openenv/auto/三块。
5.1 一个环境到底是什么
先建立心智模型。openenv init my_env 生成的目录长这样(README「Project Structure」节):
my_env/
├── __init__.py 导出 Action / Observation / 客户端类
├── models.py 数据类型定义
├── client.py EnvClient 子类
├── openenv.yaml 环境清单(manifest)
├── pyproject.toml 依赖 + 包配置
├── README.md 文档,同时是 HF Space 的首页
└── server/
├── my_environment.py Environment 子类
├── app.py FastAPI 应用
├── requirements.txt
└── Dockerfile
关键在于这个目录一身三任:
| 身份 | 由哪部分承担 | 谁消费 |
|---|---|---|
| Python 包(客户端) | __init__.py、client.py、models.py | pip install git+https://huggingface.co/spaces/... |
| Docker 镜像源 | server/、Dockerfile | docker run 或 HF Spaces 构建 |
| 在线服务 | 部署后的 Space | EnvClient(base_url="https://...hf.space") |
客户端代码和服务端代码在同一个仓库,但严格不互相 import——这是仓库列出的架构不变量之一,共享的东西放 models.py。
openenv.yaml:极简清单
完整内容就六行(envs/echo_env/openenv.yaml):
spec_version: 1
name: echo_env
type: space
runtime: fastapi
app: server.app:app
port: 8000
它是 AutoEnv 自动发现的锚点,见本章 5.6。
5.2 CLI 六件套
入口注册在 src/openenv/cli/__main__.py:36-62,用 Typer 搭的。用 app.command() 注册的一级命令正好六个,构成主线:
| 命令 | 干什么 | 实现 |
|---|---|---|
openenv init <name> | 从模板生成环境骨架 | cli/commands/init.py |
openenv build | 构建 Docker 镜像 | cli/commands/build.py |
openenv validate | 校验结构与部署就绪度 | cli/commands/validate.py + cli/_validation.py |
openenv push | 推到 HF Spaces | cli/commands/push.py |
openenv fork <space-id> | 复制别人的 Space 到自己账号 | cli/commands/fork.py |
openenv collect | 从已部署环境采 rollout 数据集 | cli/commands/collect.py |
主线之外还挂着两个东西,都不是「第七件套」:
openenv skills是子命令组,不是一级命令。 它用app.add_typer()而不是app.command()挂上去(__main__.py:54-58),自己底下还有一层子命令,管理给 AI 助手用的 skills(cli/commands/skills.py)。openenv serve注册了,但没有实现。 注册行的 help 文案自己写着 "TODO: Phase 4"(__main__.py:47-49);真调用它,源码里直接打印「尚未实现」然后raise typer.Exit(1),并给出两条替代路径(cli/commands/serve.py:56-90)。README 的 CLI 清单里却列了它——这是本文档要诚实指出的一处文档与实现不一致。
init 的模板机制
模板放在 src/openenv/cli/templates/openenv_env/,通过 pyproject.toml:66-67 的 package-data 打进 wheel。
替换用的是纯文本占位符,不是模板引擎。占位符表在 _create_template_replacements()(cli/commands/init.py:213):
| 占位符 | 替换成 |
|---|---|
__ENV_NAME__ | my_env |
__ENV_CLASS_NAME__ | My(去掉 _env 后缀再 PascalCase) |
__ENV_TITLE_NAME__ | My Env |
__ENV_CAMEL_NAME__ | myEnv |
__HF_EMOJI__ / __HF_COLOR_FROM__ / __HF_COLOR_TO__ | 随机选一个,给 HF Space 首页用 |
有两个小心思:
其一,替换按长度降序执行(init.py:250-253),先换 __ENV_CLASS_NAME__Environment 这种长的,再换 __ENV_CLASS_NAME__,避免部分替换出错。
其二,文件名也参与替换。模板里有个文件叫 __ENV_NAME___environment.py,会被改名成 my_env_environment.py(_should_rename_file,init.py:259-271)。
复制时还统一把 CRLF 归一成 LF(init.py:288-292)——仓库里甚至有一个专门的测试 tests/test_line_endings.py 守这条。
5.3 Docker:两段式构建 + 基础镜像
基础镜像
src/openenv/core/containers/images/Dockerfile 构建 openenv-base。它自己也是两段的:
阶段 1(builder):ghcr.io/astral-sh/uv:0.5.27-python3.11-bookworm-slim
└─ uv pip install --system -r pyproject.toml 只装核心依赖
│ 拷贝产物
▼
阶段 2(runtime):python:3.11-slim
└─ 拷 uv 二进制 + site-packages + 控制台脚本
└─ ENV PYTHONPATH=/app/src,EXPOSE 8000,不设 CMD
最后一行注释写着「CMD 应由子 Dockerfile 指定」(images/Dockerfile:60)。
环境镜像
每个环境的 Dockerfile 都从 ghcr.io/huggingface/openenv-base:latest 出发,且这个 base 是可覆盖的 build arg(envs/echo_env/server/Dockerfile:13)。
构建里有个值得学的两趟 uv sync(echo_env/server/Dockerfile:43-55):
第一趟:uv sync --no-install-project 只装依赖 → 这层可以被缓存复用
第二趟:uv sync 再装项目本身 → 改代码只重跑这层
加上 --mount=type=cache,target=/root/.cache/uv,改一行环境代码不会重下所有依赖。
运行阶段只拷 .venv 和代码,并设:
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONPATH="/app/env:$PYTHONPATH"
ENV ENABLE_WEB_INTERFACE=true
HEALTHCHECK ... urllib.request.urlopen('http://localhost:8000/health')
CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
健康检查用 Python 而不是 curl,注释说明理由是「更可移植」(echo_env/server/Dockerfile:75)。模板版则用 curl(templates/openenv_env/server/Dockerfile:72)——两者不一致,是个小的遗留差异。
in-repo vs standalone
openenv build 会自动判断你在哪种上下文里(_detect_build_context,cli/commands/build.py:43):
| 模式 | 触发条件 | 差别 |
|---|---|---|
in-repo | 环境目录在 OpenEnv 仓库结构内 | 用仓库本地的 openenv 源码 |
standalone | 不在 git 仓库里,或在仓库结构外 | 从 PyPI/Git 装 openenv |
结果通过 BUILD_MODE build arg 传给 Dockerfile(build.py:293)。
5.4 Provider 家族:把环境跑起来的几种后端
两个抽象基类,分工明确(src/openenv/core/containers/runtime/providers.py):
| 基类 | 位置 | 核心方法 |
|---|---|---|
ContainerProvider | providers.py:18 | start_container(image, ...) -> base_url、stop_container()、wait_for_ready(base_url) |
RuntimeProvider | providers.py:660 | start(...) -> base_url、stop()、wait_for_ready() |
区别是要不要镜像:前者以镜像为输入,后者以项目路径为输入。EnvClient._start_provider_if_needed() 靠 hasattr(provider, "start_container") 区分两者(env_client.py:366-382)。
现有实现
| Provider | 文件 | 状态 |
|---|---|---|
LocalDockerProvider | providers.py:120 | 可用,本地 docker run |
DockerSwarmProvider | providers.py:329 | 可用,部署到 Swarm 集群 |
KubernetesProvider | providers.py:647 | 只是占位符,类体是 pass,连抽象方法都没实现,因此无法实例化 |
UVProvider | uv_provider.py:125 | 可用,uv run 直接跑,不需要 Docker |
DaytonaProvider | daytona_provider.py | 可选依赖 openenv[daytona] |
ACASandboxProvider | aca_provider.py | 可选依赖 openenv[aca],Azure Container Apps |
ModalProvider | modal_provider.py | 可选依赖 openenv[modal] |
HFSandboxProvider | hf_sandbox_provider.py | Hugging Face 沙箱 |
注意 runtime/__init__.py:14-17 的注释:需要额外 SDK 的云 provider 故意不 re-export,必须从具体模块导入。这样 import openenv 不会因为你没装 Azure SDK 而崩。
LocalDockerProvider 的三个小动作
- 构造时就检查 Docker 在不在——
docker version跑不通直接RuntimeError,不等到start_container才失败(providers.py:145-159); - 自动找空闲端口——绑 0 端口让内核分配(
_find_available_port,providers.py:296),容器内固定 8000,外部映射到这个随机端口; - 就绪检测轮询
/health,而且显式proxies={"http": None, "https": None}绕开本地代理(providers.py:280-290)。
UVProvider:Docker 之外的另一条路
它把 环境当普通 Python 项目跑:uv run --isolated --project <path> -- uvicorn <app> ...(_create_uv_command,uv_provider.py:72-100)。
有个不显然的实现:project_path 支持 git+https://... 前缀,但 uv run --project 只认本地目录。所以 UVProvider 自己先 git clone --depth 1 到临时目录(_clone_git_project,uv_provider.py:29),注释里把这个理由讲得很清楚。
健康轮询函数 _poll_health(uv_provider.py:103)里也有一句值得看的注释:连接被拒会立刻返回,如果不 sleep 就 continue,会在服务器启动期间空转烧掉一个 CPU 核(注释在 uv_provider.py:114-117)。
5.5 发布到 Hugging Face Spaces
openenv push 的主要工作在 _prepare_staging_directory()(cli/commands/push.py:334)。它不直接上传你的目录,而是先在暂存区做三件改造:
你的 env/ 暂存目录
├── server/Dockerfile ──▶ ├── Dockerfile ← 移到仓库根(HF 要求)
├── README.md ──▶ ├── README.md ← 补 YAML frontmatter
└── ... ──▶ └── ... ← 按 .dockerignore 过滤
为什么 Dockerfile 要移到根? 因为 HF Spaces 的 docker SDK 规定构建文件在仓库根(push.py:366-377)。
frontmatter 是什么? HF Space 靠 README 顶部的 YAML 块决定标题、emoji、配色、sdk: docker(push.py:399-437)。openenv init 生成的那些随机 emoji 和颜色,就是在这里派上用场。
创建仓库用 api.create_repo(..., space_sdk="docker")(push.py:443-462),上传用 api.upload_folder(push.py:470-498)。openenv fork 则直接调 HF 的 duplicate_space API(cli/commands/fork.py:152)。
于是分发闭环成立
openenv push
│
▼
HF Space(一个 git 仓库)
│
├──▶ HF 自动构建 Docker 镜像 → registry.hf.space/{org}-{space}:latest
├──▶ Space 在线运行 → https://{org}-{space}.hf.space
└──▶ 仓库本身可 pip install → 客户端代码
三种消费方式分别对应 EnvClient.from_env(use_docker=True)、EnvClient(base_url=...) 和 pip install git+...。
5.6 AutoEnv:仿 AutoModel 的自动发现
目标是这一行(src/openenv/auto/__init__.py:15-16):
env = AutoEnv.from_name("coding-env")
发现流程
① 扫 importlib.metadata,找 openenv-* 开头的已装包
② 从包资源里读 openenv.yaml
③ 按命名约定推导类名: echo_env → EchoEnv / EchoAction / EchoObservation
④ 结果写进本地缓存
对应 EnvironmentDiscovery(src/openenv/auto/_discovery.py:339)和 _create_env_info_from_package(:258)。类名推导在 _infer_class_name(:190),清单里显式写了 action/observation 的话优先用清单(_discovery.py:299-309)。
Hub 分支
名字看起来像 Hub repo id 或 URL 时(_is_hub_url,_discovery.py:168),AutoEnv.from_env()(auto_env.py:497)走另一条路:
Space 在线吗?
│
┌──┴──┐
在线 不在线
│ │
▼ ▼
装客户端包 装客户端包
连远端 URL 本地起 Docker(registry.hf.space 镜像)
判断在 auto_env.py:645-669。
安全阀:trust_remote_code
从 Hub 装包等于在本地执行别人的代码。所以有确认环节 _confirm_remote_install()(auto_env.py:75),可用 trust_remote_code=True 或 OPENENV_TRUST_REMOTE_CODE 环境变量跳过。
更保守的选项是 skip_install=True:完全不 装包,回退到 GenericEnvClient 收发裸 dict(auto_env.py:573-637)。这条路的取舍很清楚——牺牲类型安全,换「一行远端代码都不在本地跑」。
错误信息做得不错
找不到环境时会用 difflib.get_close_matches 给拼写建议(auto_env.py:689-697):
Unknown environment 'codeing_env'.
Did you mean: coding_env?
Available environments: ...
5.7 openenv validate:两种校验
| 校验对象 | 函数 | 检查什么 |
|---|---|---|
| 本地目录 | validate_multi_mode_deployment(cli/_validation.py:505) | 有没有 openenv.yaml、app.py 里有没有 main() 和 __main__ 守卫 、Dockerfile 装没装 openenv 运行时 |
| 运行中的服务 | validate_running_environment(cli/_validation.py:99) | 逐条打分,产出可进 CI 的 JSON 报告 |
运行时校验的第一条准则很典型:GET /openapi.json 必须返回带 info.version 的合法 OpenAPI 文档(_validation.py:127-176)。因为 create_fastapi_app 里写死了 version="1.0.0"(http_server.py:1829),这条实际上在验证「这确实是个 OpenEnv 服务」。
5.8 关键细节与坑
openenv serve不可用。 README 的 CLI 清单里有它,实现是个说明页 + 退出码 1(cli/commands/serve.py)。替代方案是openenv build+docker run,或uv run --project . server。KubernetesProvider不能实例化。 类体是pass,没实现抽象方法(providers.py:647-657),README 标注为「planned」。- 环境依赖是分层的。 根
pyproject.toml只有 fastapi/pydantic/uvicorn/typer/fastmcp/gradio 这类共用件;torch、numpy、smolagents 这些重家伙必须放到各环境自己的pyproject.toml(pyproject.toml:14-16的注释)。 - 环境的双导入写法。 每个环境的
app.py和*_environment.py都用try: 相对导入 / except ImportError: 绝对导入(如envs/echo_env/server/app.py:26-37),为的是同一份代码在「仓库内」和「独立 Space」两种布局下 都能跑。 .dockerignore与 push 排除是两套。 push 有自己的忽略模式加载逻辑(_load_ignore_patterns,push.py:147),支持--exclude-file。
5.9 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| CLI 入口 | src/openenv/cli/__main__.py | app、main |
| 脚手架 | src/openenv/cli/commands/init.py | _create_template_replacements、_copy_template_directory |
| 构建上下文探测 | src/openenv/cli/commands/build.py | _detect_build_context、_build_docker_image |
| HF 推送 | src/openenv/cli/commands/push.py | _prepare_staging_directory、_create_hf_space、_upload_to_hf_space |
| Space 复制 | src/openenv/cli/commands/fork.py | fork |
| 校验 | src/openenv/cli/_validation.py | validate_running_environment、validate_multi_mode_deployment |
| 子命令组 | src/openenv/cli/commands/skills.py | app |
| 未实现命令 | src/openenv/cli/commands/serve.py | serve |
| Provider 基类 | src/openenv/core/containers/runtime/providers.py | ContainerProvider、RuntimeProvider |
| 本地 Docker | src/openenv/core/containers/runtime/providers.py | LocalDockerProvider |
| uv 运行时 | src/openenv/core/containers/runtime/uv_provider.py | UVProvider、_clone_git_project、_create_uv_command、_poll_health |
| 基础镜像 | src/openenv/core/containers/images/Dockerfile | — |
| 环境镜像范例 | envs/echo_env/server/Dockerfile | — |
| 模板 | src/openenv/cli/templates/openenv_env/ | — |
| 自动发现 | src/openenv/auto/_discovery.py | EnvironmentDiscovery、_create_env_info_from_package、_infer_class_name |
| 自动装载 | src/openenv/auto/auto_env.py | AutoEnv.from_env、_ensure_package_from_hub、_confirm_remote_install |
| 环境清单 | envs/echo_env/openenv.yaml | — |