# PR #1900 完整报告

- 仓库：`radixark/miles`
- 标题：Read the slurm allocation when the train config is built
- 合并时间：2026-08-09 18:34
- 原文链接：http://prhub.com.cn/radixark/miles/pull/1900

---

# 执行摘要

- 一句话：修复 SLURM 节点数在 import 时固化，改为构造时读取
- 推荐动作：建议精读。该 PR 虽然改动量小，但揭示了 dataclass 默认值在 CLI 框架（click/typer）中的经典陷阱，并给出了 `default_factory` 延迟求值与 `_resolve_default` 桥接的干净解法。对于任何需要将 dataclass 配置暴露为命令行参数的代码，都有直接参考价值。同时它展示了如何在重构链中用小步提交修复底层语义问题，为后续测试基建铺路。

# 功能与动机

该 PR 是 tracking issue #1837「Refactoring and enhancements」系列的一部分，目标是为启动脚本建立可复现的快照测试。commit 消息指出：`ExecuteTrainConfig.num_nodes` 原来以类级默认值读取环境变量，导致值在 `command_utils` 被导入时就固定下来，测试无法用 `monkeypatch` 还原，进程在导入后设置该变量也不生效。改为 `default_factory` 可在每次构造时读取。同时 `dataclass_cli` 复制参数默认值时，对 factory 字段会复制 dataclasses 的哨兵 `_HAS_DEFAULT_FACTORY`，click 会对该哨兵做类型转换导致失败，因此需要 `_resolve_default` 立即调用 factory 获得真实默认值。

# 实现拆解

实现分为四步：

1. **修改 `command_utils.py` 的 `ExecuteTrainConfig`**：将 `num_nodes: int = int(os.environ.get("SLURM_JOB_NUM_NODES", "1"))` 改为 `num_nodes: int = field(default_factory=lambda: int(os.environ.get("SLURM_JOB_NUM_NODES", "1")))`，使环境变量在每次实例构造时读取，修复导入时固化的问题。同时将 `from dataclasses import dataclass` 扩展为 `from dataclasses import dataclass, field`。

2. **在 `typer_utils.py` 新增 `_resolve_default` 助手**：该函数检查 `field.default_factory` 是否为 `dataclasses.MISSING`，若非则立即调用 factory 返回真实默认值，否则返回 `param.default`。
3. **在 `_wrap` 中应用 `_resolve_default`**：构造新参数时，通过 `param.replace(annotation=new_annotation, default=_resolve_default(field, param))` 将真实默认值写入 click/typer 签名，避免哨兵对象进入命令行解析。
4. **补充测试**：`test_command_utils.py` 新增 `TestExecuteTrainConfig.test_num_nodes_reads_the_slurm_allocation_when_the_config_is_built`，验证构造实例时读取环境变量；`test_typer_utils.py` 新增 `test_a_field_with_a_default_factory_is_usable_from_the_command_line`，验证带 `default_factory` 的字段可通过命令行参数覆盖默认值。无额外配置或部署改动。

关键文件：
- `miles/utils/typer_utils.py`（模块 CLI 工具；类别 source；类型 core-logic；符号 _resolve_default, _wrap）: 核心逻辑变更：新增 `_resolve_default` 处理 dataclass `default_factory` 字段，并应用于 `_wrap` 的参数签名构建，是保证 CLI 正确解析的关键。
- `miles/utils/external_utils/command_utils.py`（模块 命令工具；类别 source；类型 dependency-wiring；符号 ExecuteTrainConfig）: 直接修复点：`ExecuteTrainConfig.num_nodes` 从类级默认值改为 `default_factory`，使环境变量在构造时读取，这是本 PR 的主要行为变更。
- `tests/fast/utils/test_command_utils.py`（模块 测试；类别 test；类型 test-coverage；符号 TestExecuteTrainConfig, test_num_nodes_reads_the_slurm_allocation_when_the_config_is_built）: 新增 `TestExecuteTrainConfig` 验证构造时读取 SLURM 环境变量，保证修复行为被测试覆盖。
- `tests/fast/utils/test_typer_utils.py`（模块 测试；类别 test；类型 test-coverage；符号 test_a_field_with_a_default_factory_is_usable_from_the_command_line, _FactoryArgs, cmd）: 新增 `test_a_field_with_a_default_factory_is_usable_from_the_command_line` 覆盖 `dataclass_cli` 对 factory 字段的解析，防止哨兵问题回归。

关键符号：_resolve_default, _wrap, test_a_field_with_a_default_factory_is_usable_from_the_command_line, test_num_nodes_reads_the_slurm_allocation_when_the_config_is_built

## 关键源码片段

### `miles/utils/typer_utils.py`

核心逻辑变更：新增 `_resolve_default` 处理 dataclass `default_factory` 字段，并应用于 `_wrap` 的参数签名构建，是保证 CLI 正确解析的关键。

```python
# miles/utils/typer_utils.py

def _resolve_default(field: dataclasses.Field, param: inspect.Parameter) -> object:
    # 当 dataclass 字段声明了 default_factory 时 , dataclasses 的哨兵 _HAS_DEFAULT_FACTORY 才是默认值
    # click 会对这个哨兵做类型转换 , 导致类似 int 字段解析失败 , 所以这里要提前调用 factory
    if field.default_factory is not dataclasses.MISSING:
        return field.default_factory()
    return param.default


def _wrap(func: _F, *, env_var_prefix: str) -> _F:
    ...
    new_parameters: list[inspect.Parameter] = []
    for param in old_parameters:
        field: dataclasses.Field = fields_by_name[param.name]

        typer_kwargs: dict[str, object] = {}
        if env_var_prefix:
            typer_kwargs["envvar"] = f"{env_var_prefix}{param.name.upper()}"
        if "help" in field.metadata:
            typer_kwargs["help"] = field.metadata["help"]

        resolved_type: type = resolved_hints.get(param.name, param.annotation)
        new_annotation = Annotated[resolved_type, typer.Option(**typer_kwargs)]

        # 关键 : 用 _resolve_default 得到真实默认值 , 而不是 dataclasses 的哨兵
        new_parameters.append(
            param.replace(annotation=new_annotation, default=_resolve_default(field, param))
        )
    ...
    wrapped.__signature__ = init_sig.replace(parameters=new_parameters)
    ...
    return wrapped

```

### `miles/utils/external_utils/command_utils.py`

直接修复点：`ExecuteTrainConfig.num_nodes` 从类级默认值改为 `default_factory`，使环境变量在构造时读取，这是本 PR 的主要行为变更。

```python
# miles/utils/external_utils/command_utils.py
from dataclasses import dataclass, field

# 该配置类被各种启动脚本继承 , num_nodes 需要跟随 SLURM 分配动态变化
@dataclass
class ExecuteTrainConfig:
    cuda_core_dump: bool = False
    # 原来这里直接写 int(os.environ.get(...)) 会在 import 时固化 , 现在改为 default_factory
    # 每次 ExecuteTrainConfig() 构造时重新读取环境变量 , monkeypatch 和运行时修改都能生效
    num_nodes: int = field(default_factory=lambda: int(os.environ.get("SLURM_JOB_NUM_NODES", "1")))
    extra_env_vars: str = ""
    output_dir: str = "/root/shared_data"

```

# 评论区精华

PR 由 yueming-yuan 直接批准（APPROVED），未留下任何 review 评论。关联 issue #1837 上的评论仅为 Gemini Code Assist 停用提示，与本改动无关。因此没有实质的设计争议或未解决问题。

- 暂无高价值评论线程

# 风险与影响

- 风险：技术风险较小但需注意两点：

1. **行为变更**：`ExecuteTrainConfig.num_nodes` 从导入时读取改为构造时读取。若现有调用方依赖“导入后环境变量不再影响”的旧行为，可能产生差异；但该改动的预期方向即修复这一语义问题，属于有意的行为修正。
2. **default_factory 重复调用**：在 `dataclass_cli` 包装的 CLI 启动时，每次构建签名都会调用一次 `_resolve_default`，若 factory 有副作用或开销，可能被多次执行。当前 factory 仅为环境变量读取，风险极低。另外，`_wrap` 中构造的签名默认值从哨兵变为真实值，任何依赖 `__signature__` 内省的工具（如自动文档生成）可能会看到不同的默认表示。
- 影响：影响范围集中在两块：

- **`miles/utils/typer_utils.py`**：所有使用 `dataclass_cli` 装饰器的启动脚本都会受影响，尤其是有 `default_factory` 字段的 dataclass 配置，现在能够被 click 正确解析。
- **`miles/utils/external_utils/command_utils.py`**：`ExecuteTrainConfig` 及其子类的行为更符合直觉，且为后续快照测试（如 PR #1901）提供了可重复的 `num_nodes` 取值，保证生成命令的确定性。

对普通用户而言此改动透明，主要受益者是维护启动脚本和快照测试的工程师。

- 风险标记：环境变量读取时机变更 , 核心工具函数变更 , 测试覆盖补充

# 关联脉络

- PR #1901 Snapshot the commands and generated configs of every python launch script: 本 PR 修复了 num_nodes 在导入时固化的问题，直接为 #1901 的快照测试提供确定性基础，使其能可靠记录和比对命令。
- PR #1902 Cover the public surface of command_utils with unit tests: 与 #1902 同属于对 `command_utils` 的测试加固系列，本 PR 新增的 `TestExecuteTrainConfig` 补充了 `ExecuteTrainConfig` 的覆盖。
- PR #1909 Expand the model args in python before building the command: 同属 #1837 重构链中的命令构建与参数处理改造，本 PR 对 `dataclass_cli` 默认值处理的修复为后续参数展开重构扫清了障碍。