跳到主要内容

数据截至 (上游 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__.pyclient.pymodels.pypip install git+https://huggingface.co/spaces/...
Docker 镜像源server/Dockerfiledocker run 或 HF Spaces 构建
在线服务部署后的 SpaceEnvClient(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 Spacescli/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-67package-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):

基类位置核心方法
ContainerProviderproviders.py:18start_container(image, ...) -> base_urlstop_container()wait_for_ready(base_url)
RuntimeProviderproviders.py:660start(...) -> base_urlstop()wait_for_ready()

区别是要不要镜像:前者以镜像为输入,后者以项目路径为输入。EnvClient._start_provider_if_needed()hasattr(provider, "start_container") 区分两者(env_client.py:366-382)。

现有实现

Provider文件状态
LocalDockerProviderproviders.py:120可用,本地 docker run
DockerSwarmProviderproviders.py:329可用,部署到 Swarm 集群
KubernetesProviderproviders.py:647只是占位符,类体是 pass,连抽象方法都没实现,因此无法实例化
UVProvideruv_provider.py:125可用,uv run 直接跑,不需要 Docker
DaytonaProviderdaytona_provider.py可选依赖 openenv[daytona]
ACASandboxProvideraca_provider.py可选依赖 openenv[aca],Azure Container Apps
ModalProvidermodal_provider.py可选依赖 openenv[modal]
HFSandboxProviderhf_sandbox_provider.pyHugging Face 沙箱

注意 runtime/__init__.py:14-17 的注释:需要额外 SDK 的云 provider 故意不 re-export,必须从具体模块导入。这样 import openenv 不会因为你没装 Azure SDK 而崩。

LocalDockerProvider 的三个小动作

  1. 构造时就检查 Docker 在不在——docker version 跑不通直接 RuntimeError,不等到 start_container 才失败(providers.py:145-159);
  2. 自动找空闲端口——绑 0 端口让内核分配(_find_available_port,providers.py:296),容器内固定 8000,外部映射到这个随机端口;
  3. 就绪检测轮询 /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=TrueOPENENV_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.yamlapp.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__.pyappmain
脚手架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.pyfork
校验src/openenv/cli/_validation.pyvalidate_running_environmentvalidate_multi_mode_deployment
子命令组src/openenv/cli/commands/skills.pyapp
未实现命令src/openenv/cli/commands/serve.pyserve
Provider 基类src/openenv/core/containers/runtime/providers.pyContainerProviderRuntimeProvider
本地 Dockersrc/openenv/core/containers/runtime/providers.pyLocalDockerProvider
uv 运行时src/openenv/core/containers/runtime/uv_provider.pyUVProvider_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.pyEnvironmentDiscovery_create_env_info_from_package_infer_class_name
自动装载src/openenv/auto/auto_env.pyAutoEnv.from_env_ensure_package_from_hub_confirm_remote_install
环境清单envs/echo_env/openenv.yaml