注释与提示信息全面中文化:源码 66 个 .py + 脚本/配置 45 个文件
背景:现场读代码的人只读中文。此前代码里的 docstring 是英文、行内注释是中文,
一半英文一半中文反而更难看懂 —— 新手打开一个 .py 不知道这个模块为什么存在。
范围(本仓库自己的代码,厂商上游不动)
src/arm_application/** 37 个文件:servo_node / perception_node / handeye_* / dh_pgea /
yolo_detector / kalman_filter / goto_ee_plane / dual_gripper …
src/arm_interfaces/** 11 个文件:paths / manifest / loader / config_guard / errors …
src/arm_control/** 5 个文件
src/arm_bringup/** 3 个 launch
scripts/** 6 个 .py + 29 个 .sh(scripts/ 是"唯一操作入口",用户屏幕上
看到的就是这些提示,所以提示信息也一并中文化)
config/ handeye/ devices/ src/arm_*/config/ 17 个 .yaml/.xml/.xacro/.cfg
口径(写进 docs/FRAMEWORK_RULES.md 新增的 R6.5,作为长期约束)
注释、docstring、print/日志/异常里给人看的句子 = 中文;
标识符、API 名、话题名、参数名、字段名、命令名 = 保留英文原文(用 `` `` 包起来);
单位、十六进制、协议报文、CRC 值、命令示例 = 原样保留;
src/flexiv_*/ = 厂商上游代码,不许改也不许翻译(R4.4)。
翻译保原意与原有详细程度:那些"为什么不能用另一种做法""踩过什么坑"的说明
全部完整保留 —— 它们才是这份代码里最贵的部分。
新增 scripts/check_text_only.py:只改文字时的自证工具
人工 review 靠不住(115 个文件、3000 多行 diff)。这个脚本把改动前后的源码解析成
语法树,删掉 docstring、抹平字符串常量、把 f-string 只留"插值了几次 + 插值是什么",
再比对:完全相同 = 改动范围内没有逻辑变化。
留了一个口子:f-string 的插值按内容排序后再比 —— 因为中文语序常常要和英文反着来
(f"on {port} read {idx} failed" → f"在 {port} 上读 {idx} 失败"),只换显示顺序不该判失败;
但多插一个、少插一个、换掉被插入的表达式,立刻会报出来。
本次 66 个 .py 全部通过该闸门。
验收
改动的 66 个 .py:scripts/check_text_only.py 全部通过(零逻辑变化)
45 个非 .py 文件:去注释后与 HEAD 逐字节相同(零配置变化)
docstring 总数:没有一个文件减少(不存在"翻译时顺手删了说明")
单测:arm_application 233 / arm_driver_flexiv 69 / arm_interfaces 46 /
arm_kinematics 24 / arm_sim_gazebo 236 = 608 passed,与改动前一致
全部 .sh:bash -n 通过;并额外实际执行了 --help 验证(见下一个提交:
bash -n 对 CRLF 脚本会漏报,只有真跑才暴露)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
"""Device contract layer for the robotic arm stack.
|
||||
"""机械臂栈的设备契约层。
|
||||
|
||||
Public entry points::
|
||||
公开入口::
|
||||
|
||||
from arm_interfaces import load_device_stack, check_requirement
|
||||
|
||||
stack = load_device_stack() # raises ContractError with a fix hint
|
||||
check_requirement(stack, "handeye") # raises CapabilityError if unmet
|
||||
stack = load_device_stack() # 失败时抛出带修复提示的 ContractError
|
||||
check_requirement(stack, "handeye") # 能力不满足时抛出 CapabilityError
|
||||
|
||||
Everything device-selection related funnels through here so that the rest of the
|
||||
framework never reads ``devices/`` or guesses at ``site.yaml`` structure itself.
|
||||
所有与设备选型相关的事都汇集到这里,框架其余部分因此永远不必自己读
|
||||
``devices/``,也不必猜测 ``site.yaml`` 的结构。
|
||||
"""
|
||||
|
||||
from .capabilities import (
|
||||
@@ -42,7 +42,7 @@ from .registry import Registry, list_available_brands, load_registry
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = [
|
||||
# loading
|
||||
# 加载
|
||||
"load_device_stack",
|
||||
"load_registry",
|
||||
"load_manifest",
|
||||
@@ -54,18 +54,18 @@ __all__ = [
|
||||
"effective_capabilities",
|
||||
"list_available_brands",
|
||||
"manifest_path",
|
||||
# types
|
||||
# 类型
|
||||
"DeviceStack",
|
||||
"Manifest",
|
||||
"Registry",
|
||||
# capabilities
|
||||
# 能力
|
||||
"ALL_CAPABILITIES",
|
||||
"CAPABILITIES_BY_KIND",
|
||||
"REQUIREMENTS",
|
||||
"ROS_INTERFACE",
|
||||
"capabilities_for_kind",
|
||||
"describe",
|
||||
# errors
|
||||
# 错误
|
||||
"ContractError",
|
||||
"RegistryError",
|
||||
"ManifestError",
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
"""Capability vocabulary and the requirement sets upper layers depend on.
|
||||
"""能力词表,以及上层依赖的能力需求集。
|
||||
|
||||
Two distinct ideas live here, and keeping them separate matters:
|
||||
这里有彼此不同的两个概念,把它们分开很重要:
|
||||
|
||||
``Capability``
|
||||
What a *driver* can do. Declared by each brand's ``manifest.yaml``. Adding a
|
||||
new brand never changes this file — only edit it when there is genuinely a
|
||||
new kind of motion or sensing to express.
|
||||
*驱动*能做什么。由每个品牌的 ``manifest.yaml`` 声明。新增品牌从不改动
|
||||
本文件——只有当确实要表达一种新的运动或感知方式时才编辑它。
|
||||
|
||||
``Requirement``
|
||||
What an *upper layer* needs. A flow (tracking, hand-eye calibration, the
|
||||
through-gauge assembly state machine) declares the set of capabilities it
|
||||
will call. The contract layer checks the selected drivers satisfy it at
|
||||
startup, so a missing capability fails immediately with a clear message
|
||||
instead of failing mid-motion.
|
||||
*上层*需要什么。一个流程(视觉跟踪、手眼标定、通止规装配状态机)声明
|
||||
它将要调用的能力集合。契约层在启动时检查所选驱动是否满足它,因此缺失
|
||||
能力会立刻报出清晰的消息,而不是在运动中途才失败。
|
||||
|
||||
The ``ROS_INTERFACE`` table maps each capability to the concrete ROS 2 endpoint
|
||||
that implements it. It exists so error messages can name the interface an
|
||||
operator should check, and so future drivers know exactly what to publish
|
||||
or serve.
|
||||
``ROS_INTERFACE`` 表把每项能力映射到实现它的具体 ROS 2 端点。它的存在,
|
||||
一是让错误消息能点名操作员该检查哪个接口,二是让未来的驱动清楚自己该
|
||||
发布或提供什么。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -26,17 +22,17 @@ from typing import Dict, FrozenSet, NamedTuple, Tuple
|
||||
|
||||
|
||||
class CapabilityInfo(NamedTuple):
|
||||
"""Documentation for one capability."""
|
||||
"""对某一项能力的说明。"""
|
||||
|
||||
owner: str
|
||||
"""Which device kind provides it: robot | gripper | sensor | camera."""
|
||||
"""由哪类设备提供:robot | gripper | sensor | camera。"""
|
||||
|
||||
summary: str
|
||||
"""One-line description of what the capability does."""
|
||||
"""一句话说明该能力做什么。"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Robot capabilities
|
||||
# 机器人能力
|
||||
# ---------------------------------------------------------------------------
|
||||
ROBOT_CAPABILITIES: Dict[str, CapabilityInfo] = {
|
||||
"get_arm_state": CapabilityInfo(
|
||||
@@ -72,7 +68,7 @@ ROBOT_CAPABILITIES: Dict[str, CapabilityInfo] = {
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gripper capabilities
|
||||
# 夹爪能力
|
||||
# ---------------------------------------------------------------------------
|
||||
GRIPPER_CAPABILITIES: Dict[str, CapabilityInfo] = {
|
||||
"open": CapabilityInfo("gripper", "张开。"),
|
||||
@@ -85,7 +81,7 @@ GRIPPER_CAPABILITIES: Dict[str, CapabilityInfo] = {
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sensor capabilities
|
||||
# 传感器能力
|
||||
# ---------------------------------------------------------------------------
|
||||
SENSOR_CAPABILITIES: Dict[str, CapabilityInfo] = {
|
||||
"read_tangential": CapabilityInfo(
|
||||
@@ -100,7 +96,7 @@ SENSOR_CAPABILITIES: Dict[str, CapabilityInfo] = {
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Camera capabilities
|
||||
# 相机能力
|
||||
# ---------------------------------------------------------------------------
|
||||
CAMERA_CAPABILITIES: Dict[str, CapabilityInfo] = {
|
||||
"color": CapabilityInfo("camera", "彩色图。推理与跟踪依赖它。"),
|
||||
@@ -116,7 +112,7 @@ CAMERA_CAPABILITIES: Dict[str, CapabilityInfo] = {
|
||||
"set_exposure": CapabilityInfo("camera", "设置曝光。跟踪需要限制曝光避免运动拖影。"),
|
||||
}
|
||||
|
||||
#: Every capability name known to the framework, tagged with its device kind.
|
||||
#: 框架已知的全部能力名,并标注其设备类别。
|
||||
ALL_CAPABILITIES: Dict[str, CapabilityInfo] = {
|
||||
**ROBOT_CAPABILITIES,
|
||||
**GRIPPER_CAPABILITIES,
|
||||
@@ -124,7 +120,7 @@ ALL_CAPABILITIES: Dict[str, CapabilityInfo] = {
|
||||
**CAMERA_CAPABILITIES,
|
||||
}
|
||||
|
||||
#: Device kind -> the capability names that kind is allowed to declare.
|
||||
#: 设备类别 -> 该类设备允许声明的能力名。
|
||||
CAPABILITIES_BY_KIND: Dict[str, FrozenSet[str]] = {
|
||||
"robot": frozenset(ROBOT_CAPABILITIES),
|
||||
"gripper": frozenset(GRIPPER_CAPABILITIES),
|
||||
@@ -134,10 +130,10 @@ CAPABILITIES_BY_KIND: Dict[str, FrozenSet[str]] = {
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ROS 2 interface mapping
|
||||
# ROS 2 接口映射
|
||||
# ---------------------------------------------------------------------------
|
||||
class RosInterface(NamedTuple):
|
||||
"""The concrete ROS 2 endpoint implementing a capability."""
|
||||
"""实现某项能力的具体 ROS 2 端点。"""
|
||||
|
||||
namespace: str
|
||||
name: str
|
||||
@@ -145,10 +141,10 @@ class RosInterface(NamedTuple):
|
||||
type: str
|
||||
|
||||
|
||||
#: capability -> ROS 2 endpoint. Names are relative to the device namespace and
|
||||
#: are resolved per-instance (two arms never share a namespace).
|
||||
#: capability -> ROS 2 端点。名称相对于设备命名空间,且按实例解析
|
||||
#:(两条机械臂绝不共用命名空间)。
|
||||
ROS_INTERFACE: Dict[str, RosInterface] = {
|
||||
# --- robot ---
|
||||
# --- 机器人 ---
|
||||
"get_arm_state": RosInterface("", "get_arm_state", "service", "arm_msgs/srv/GetArmState"),
|
||||
"follow_joint_trajectory": RosInterface(
|
||||
"", "follow_joint_trajectory", "action", "arm_msgs/action/FollowJointTrajectory"
|
||||
@@ -164,19 +160,19 @@ ROS_INTERFACE: Dict[str, RosInterface] = {
|
||||
"enable": RosInterface("", "enable", "service", "std_srvs/srv/SetBool"),
|
||||
"clear_fault": RosInterface("", "clear_fault", "service", "std_srvs/srv/Trigger"),
|
||||
"soft_drag": RosInterface("", "soft_drag", "service", "std_srvs/srv/SetBool"),
|
||||
# --- gripper ---
|
||||
# --- 夹爪 ---
|
||||
"open": RosInterface("", "open", "service", "arm_msgs/srv/GripperExecute"),
|
||||
"close": RosInterface("", "close", "service", "arm_msgs/srv/GripperExecute"),
|
||||
"move": RosInterface("", "move", "service", "arm_msgs/srv/GripperExecute"),
|
||||
"rotate": RosInterface("", "rotate", "service", "arm_msgs/srv/GripperExecute"),
|
||||
"get_status": RosInterface("", "status", "topic", "arm_msgs/msg/GripperStatus"),
|
||||
# --- sensor ---
|
||||
# --- 传感器 ---
|
||||
"read_tangential": RosInterface("", "tangential", "topic", "sensor_msgs/msg/Float32MultiArray"),
|
||||
"read_normal": RosInterface("", "normal", "topic", "sensor_msgs/msg/Float32"),
|
||||
"read_depth": RosInterface("", "depth", "topic", "sensor_msgs/msg/Image"),
|
||||
"read_force_vector": RosInterface("", "force_vector", "topic", "sensor_msgs/msg/Image"),
|
||||
"measure_baseline": RosInterface("", "measure_baseline", "service", "std_srvs/srv/Trigger"),
|
||||
# --- camera ---
|
||||
# --- 相机 ---
|
||||
"color": RosInterface("", "color/image_raw", "topic", "sensor_msgs/msg/Image"),
|
||||
"depth": RosInterface("", "depth/image_raw", "topic", "sensor_msgs/msg/Image"),
|
||||
"aligned_depth_to_color": RosInterface(
|
||||
@@ -192,10 +188,10 @@ ROS_INTERFACE: Dict[str, RosInterface] = {
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Requirement sets
|
||||
# 需求集
|
||||
# ---------------------------------------------------------------------------
|
||||
class Requirement(NamedTuple):
|
||||
"""A named flow and the capabilities it needs, grouped by device kind."""
|
||||
"""一个具名流程及其所需能力,按设备类别分组。"""
|
||||
|
||||
summary: str
|
||||
robot: Tuple[str, ...]
|
||||
@@ -261,12 +257,12 @@ REQUIREMENTS: Dict[str, Requirement] = {
|
||||
|
||||
|
||||
def capabilities_for_kind(kind: str) -> FrozenSet[str]:
|
||||
"""Return the set of capability names valid for a device kind."""
|
||||
"""返回某类设备合法的能力名集合。"""
|
||||
return CAPABILITIES_BY_KIND.get(kind, frozenset())
|
||||
|
||||
|
||||
def describe(capability: str) -> str:
|
||||
"""Human-readable description used in error messages."""
|
||||
"""用于错误消息的、给人看的说明。"""
|
||||
info = ALL_CAPABILITIES.get(capability)
|
||||
if info is None:
|
||||
return f"{capability}(框架未登记的能力名)"
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"""``device_check`` — validate the device stack from the command line.
|
||||
"""``device_check`` —— 从命令行校验设备栈。
|
||||
|
||||
Usage::
|
||||
用法::
|
||||
|
||||
ros2 run arm_interfaces device_check # load everything, print summary
|
||||
ros2 run arm_interfaces device_check # 全量加载并打印摘要
|
||||
ros2 run arm_interfaces device_check --requirement handeye
|
||||
ros2 run arm_interfaces device_check --json
|
||||
ros2 run arm_interfaces device_check --list-brands
|
||||
ros2 run arm_interfaces device_check --no-assets # skip vendor asset checks
|
||||
ros2 run arm_interfaces device_check --no-assets # 跳过厂商物料检查
|
||||
|
||||
Exit codes are meaningful so scripts can branch on them:
|
||||
退出码有明确含义,脚本可据此分支:
|
||||
|
||||
=== ==========================================================
|
||||
0 contract satisfied
|
||||
2 contract violation (missing brand, config, asset, capability)
|
||||
3 contract layer itself broke (unexpected exception)
|
||||
0 契约满足
|
||||
2 契约违规(缺少品牌、配置、物料或能力)
|
||||
3 契约层自身出错(意料之外的异常)
|
||||
=== ==========================================================
|
||||
"""
|
||||
|
||||
@@ -119,7 +119,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
print(format_error(err), file=sys.stderr)
|
||||
return EXIT_CONTRACT
|
||||
|
||||
except Exception as exc: # noqa: BLE001 - the CLI is the last line of defence
|
||||
except Exception as exc: # noqa: BLE001 - CLI 是最后一道防线
|
||||
print(
|
||||
f"契约层内部错误(这本身是框架 bug,请上报): {type(exc).__name__}: {exc}",
|
||||
file=sys.stderr,
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
"""Validate ``config/site.yaml`` against a manifest's declared ``config_schema``.
|
||||
"""按清单声明的 ``config_schema`` 校验 ``config/site.yaml``。
|
||||
|
||||
Two classes of bug motivated this module:
|
||||
有两类 bug 促成了本模块:
|
||||
|
||||
1. **Silent no-ops.** ``run_handeye.sh`` passed five parameters
|
||||
(``settle_qd_max``, ``pin_holds``, ...) to a node that never declared them.
|
||||
YAML accepted the values, nothing read them, tuning had no effect.
|
||||
2. **Unit confusion.** A value typed in millimetres but interpreted as metres
|
||||
produces a plausible-looking but wrong motion.
|
||||
1. **静默失效。** ``run_handeye.sh`` 把五个参数
|
||||
(``settle_qd_max``、``pin_holds``……)传给了一个从未声明它们的节点。
|
||||
YAML 接受了这些值,却没人读它们,调参毫无效果。
|
||||
2. **单位混淆。** 按毫米输入却被当成米解释的值,会产生看似合理但错误的
|
||||
运动。
|
||||
|
||||
The guard addresses both: every declared field is checked for presence and type,
|
||||
and ``unit`` is echoed in error messages so the operator sees the expected unit
|
||||
next to the value they wrote.
|
||||
配置守卫同时解决这两点:每个已声明的字段都会检查是否存在、类型是否正确,
|
||||
并且 ``unit`` 会回显在错误消息里,让操作员在写下的值旁边就能看到期望的单位。
|
||||
|
||||
``site_path`` supports a list-expansion token so per-instance config can be
|
||||
declared once: ``robot.robots[].ip`` resolves to ``robot.robots[0].ip`` and
|
||||
``robot.robots[1].ip`` and requires *every* instance to supply the value.
|
||||
``site_path`` 支持列表展开记号,使按实例的配置只需声明一次:
|
||||
``robot.robots[].ip`` 会解析为 ``robot.robots[0].ip`` 和
|
||||
``robot.robots[1].ip``,并要求*每一个*实例都提供该值。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,9 +26,8 @@ import yaml
|
||||
from .errors import BadConfigValueError, MissingConfigError
|
||||
from .manifest import ConfigField, Manifest
|
||||
|
||||
#: Python types accepted for each declared type name. ``bool`` is deliberately
|
||||
#: excluded from the numeric types: in YAML ``true`` is a bool, and silently
|
||||
#: using it as 1.0 has caused real bugs.
|
||||
#: 每个已声明类型名所接受的 Python 类型。``bool`` 被刻意排除在数值类型之外:
|
||||
#: 在 YAML 里 ``true`` 是布尔值,把它悄悄当作 1.0 用曾引发过真实 bug。
|
||||
_TYPE_CHECKS = {
|
||||
"str": (str,),
|
||||
"int": (int,),
|
||||
@@ -41,20 +39,19 @@ _TYPE_CHECKS = {
|
||||
|
||||
|
||||
class ResolvedValue(NamedTuple):
|
||||
"""One concrete value found in ``site.yaml`` for a declared field."""
|
||||
"""在 ``site.yaml`` 中为某个已声明字段找到的一个具体值。"""
|
||||
|
||||
path: str
|
||||
"""Concrete path, e.g. ``robot.robots[1].ip``."""
|
||||
"""具体路径,例如 ``robot.robots[1].ip``。"""
|
||||
|
||||
value: Any
|
||||
|
||||
|
||||
def load_site(path: Path | None = None) -> Dict[str, Any]:
|
||||
"""Read ``config/site.yaml``.
|
||||
"""读取 ``config/site.yaml``。
|
||||
|
||||
Returns an empty dict when the file is absent, matching the lenient
|
||||
behaviour the old ``site_io.load_site_dict`` had — absence is reported by
|
||||
the guard as missing required config rather than as a parse crash.
|
||||
文件不存在时返回空字典,与旧 ``site_io.load_site_dict`` 的宽松行为保持
|
||||
一致——缺失会由配置守卫报告为“缺少必需配置”,而不是解析崩溃。
|
||||
"""
|
||||
from . import paths
|
||||
|
||||
@@ -66,30 +63,27 @@ def load_site(path: Path | None = None) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def resolve_path(site: Dict[str, Any], site_path: str) -> List[ResolvedValue]:
|
||||
"""Resolve a (possibly list-expanding) dotted path against parsed YAML.
|
||||
"""在解析后的 YAML 上解析一个(可能带列表展开的)点分路径。
|
||||
|
||||
The ``[]`` token expands a collection into one entry per member, so
|
||||
per-instance config can be declared once and still require *every* instance
|
||||
to supply it:
|
||||
``[]`` 记号会把一个集合展开成每个成员一条记录,因此按实例的配置只需
|
||||
声明一次,却仍要求*每一个*实例都提供它:
|
||||
|
||||
* sequence — ``robot.robots[].sn`` → ``robot.robots[0].sn``, ``...[1].sn``
|
||||
* mapping — ``cameras[].serial`` → ``cameras.d405_left.serial``, ...
|
||||
* 序列 — ``robot.robots[].sn`` → ``robot.robots[0].sn``、``...[1].sn``
|
||||
* 映射 — ``cameras[].serial`` → ``cameras.d405_left.serial``……
|
||||
|
||||
Mappings are supported because ``site.yaml`` registers cameras (and other
|
||||
named devices) as mappings keyed by device name rather than as sequences.
|
||||
之所以支持映射,是因为 ``site.yaml`` 把相机(以及其他具名设备)登记为
|
||||
以设备名为键的映射,而不是序列。
|
||||
|
||||
Missing branches produce no results; the caller decides whether that is an
|
||||
error. A *scalar* where a collection is expected (``cameras: 5``) is
|
||||
reported as a single value resolved at the collection level, so the
|
||||
type check can flag it instead of the operator being told, misleadingly,
|
||||
that the value is simply absent.
|
||||
缺失的分支不产生结果;是否算错误由调用方决定。在本该是集合的位置出现
|
||||
*标量*(``cameras: 5``)时,会作为在集合层级解析出的单个值上报,让类型
|
||||
检查能指出问题,而不是误导性地告诉操作员“该值不存在”。
|
||||
"""
|
||||
tokens = [t.strip() for t in site_path.split(".") if t.strip()]
|
||||
return _resolve(site, tokens, "")
|
||||
|
||||
|
||||
def _resolve(node: Any, tokens: List[str], prefix: str) -> List[ResolvedValue]:
|
||||
"""Recursive worker for :func:`resolve_path`."""
|
||||
""":func:`resolve_path` 的递归工作函数。"""
|
||||
if not tokens:
|
||||
return [ResolvedValue(path=prefix, value=node)]
|
||||
|
||||
@@ -122,8 +116,8 @@ def _resolve(node: Any, tokens: List[str], prefix: str) -> List[ResolvedValue]:
|
||||
if child is None:
|
||||
return []
|
||||
|
||||
# Scalar where a collection was declared. Stop here and let the type check
|
||||
# produce a precise complaint rather than a misleading "missing".
|
||||
# 声明为集合的位置却是标量。在这里停下,让类型检查给出精确的报错,
|
||||
# 而不是误导性的“缺失”。
|
||||
return [ResolvedValue(path=child_path, value=child)]
|
||||
|
||||
|
||||
@@ -132,17 +126,17 @@ def check_config(
|
||||
site: Dict[str, Any],
|
||||
site_file: Optional[Path] = None,
|
||||
) -> Dict[str, List[ResolvedValue]]:
|
||||
"""Validate all declared fields of a manifest against parsed site config.
|
||||
"""按解析后的现场配置校验清单中所有已声明的字段。
|
||||
|
||||
Returns a mapping of ``field.key`` to the resolved values, so callers that
|
||||
need the values anyway do not have to resolve them a second time.
|
||||
返回 ``field.key`` 到已解析值的映射,这样本来就需要这些值的调用方不必
|
||||
再解析第二遍。
|
||||
|
||||
Raises
|
||||
------
|
||||
MissingConfigError
|
||||
A required field has no value in ``site.yaml``.
|
||||
某个必需字段在 ``site.yaml`` 中没有值。
|
||||
BadConfigValueError
|
||||
A field is present but has the wrong YAML type.
|
||||
某个字段存在,但字段值的 YAML 类型不对。
|
||||
"""
|
||||
resolved: Dict[str, List[ResolvedValue]] = {}
|
||||
problems: List[str] = []
|
||||
@@ -156,9 +150,8 @@ def check_config(
|
||||
problems.append(_missing_message(field))
|
||||
continue
|
||||
|
||||
# A required field with per-instance expansion needs *every* instance
|
||||
# populated. Reporting only when all were blank would let one
|
||||
# unconfigured arm slip through.
|
||||
# 带按实例展开的必需字段需要*每一个*实例都填好。若只在全部为空时
|
||||
# 才报告,就会放过某一台未配置的机械臂。
|
||||
if field.required:
|
||||
blank_paths = [v.path for v in values if _is_blank(v.value)]
|
||||
if blank_paths:
|
||||
@@ -204,7 +197,7 @@ def _default_site_file() -> Path:
|
||||
|
||||
|
||||
def _is_blank(value: Any) -> bool:
|
||||
"""Whether a value should be treated as 'not supplied'."""
|
||||
"""判断某个值是否应被视为“未提供”。"""
|
||||
if value is None:
|
||||
return True
|
||||
if isinstance(value, str) and not value.strip():
|
||||
@@ -217,7 +210,7 @@ def _is_blank(value: Any) -> bool:
|
||||
def _matches_type(value: Any, declared: str) -> bool:
|
||||
expected = _TYPE_CHECKS.get(declared)
|
||||
if expected is None:
|
||||
# Unknown declared type is rejected at manifest load time.
|
||||
# 未知的已声明类型会在清单加载阶段被拒。
|
||||
return True
|
||||
if declared in ("int", "float") and isinstance(value, bool):
|
||||
return False
|
||||
@@ -225,11 +218,10 @@ def _matches_type(value: Any, declared: str) -> bool:
|
||||
|
||||
|
||||
def _missing_message(field: ConfigField, blank_paths: Optional[List[str]] = None) -> str:
|
||||
"""Render one missing-config problem.
|
||||
"""渲染一条缺失配置问题。
|
||||
|
||||
``blank_paths`` lists concrete per-instance paths that were present but
|
||||
empty (e.g. ``robot.robots[1].sn``), which is far more actionable than
|
||||
repeating the abstract declaration path.
|
||||
``blank_paths`` 列出存在但为空的具体按实例路径(例如
|
||||
``robot.robots[1].sn``),这远比重复抽象的声明路径更具可操作性。
|
||||
"""
|
||||
lines = [f" 缺: {field.key}"]
|
||||
if blank_paths:
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"""Structured contract errors.
|
||||
"""结构化的契约错误。
|
||||
|
||||
Every error carries a ``fix_hint`` that names the exact file and key a human
|
||||
should edit. This is deliberate: the whole point of the contract layer is to
|
||||
replace "something silently did nothing" (the old behaviour, where
|
||||
``run_handeye.sh`` passed five parameters the node never declared) with an
|
||||
actionable message.
|
||||
每个错误都带有 ``fix_hint``,明确指出人应该去改哪个文件、哪个键。这是刻意
|
||||
设计的:契约层的全部意义,就是把“某个东西静默地什么都没做”(旧行为,例如
|
||||
``run_handeye.sh`` 传给节点的五个参数节点根本不曾声明)替换成一条可执行的
|
||||
提示。
|
||||
|
||||
Callers should format errors with :func:`format_error`, which renders the
|
||||
hint on its own indented line so it is impossible to miss in a terminal.
|
||||
调用方应当用 :func:`format_error` 格式化错误,它会把修复提示单独渲染成
|
||||
一行缩进文本,在终端里绝不会被漏看。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,18 +15,17 @@ from typing import Optional
|
||||
|
||||
|
||||
class ContractError(Exception):
|
||||
"""Base class for every device-contract failure.
|
||||
"""所有设备契约失败的基类。
|
||||
|
||||
Attributes
|
||||
----------
|
||||
what:
|
||||
One-line description of what is wrong.
|
||||
一句话说明哪里不对。
|
||||
fix_hint:
|
||||
Concrete instruction naming the file and key to edit. ``None`` means
|
||||
the caller could not determine a remedy, which callers should treat as
|
||||
a bug in the contract layer rather than user error.
|
||||
指明要改哪个文件、哪个键的具体指示。``None`` 表示调用方无法确定
|
||||
补救办法,此时调用方应把它当作契约层的 bug,而不是用户错误。
|
||||
detail:
|
||||
Optional extra context (paths tried, values found, ...).
|
||||
可选的补充上下文(尝试过的路径、读到的值……)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -41,36 +39,36 @@ class ContractError(Exception):
|
||||
self.fix_hint = fix_hint
|
||||
self.detail = detail
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - exercised via format_error
|
||||
def __str__(self) -> str: # pragma: no cover - 通过 format_error 覆盖
|
||||
return format_error(self)
|
||||
|
||||
|
||||
class RegistryError(ContractError):
|
||||
"""``devices/registry.yaml`` is missing, unreadable, or incomplete."""
|
||||
"""``devices/registry.yaml`` 缺失、不可读或不完整。"""
|
||||
|
||||
|
||||
class ManifestError(ContractError):
|
||||
"""A per-brand ``manifest.yaml`` is missing, unreadable, or malformed."""
|
||||
"""某个品牌的 ``manifest.yaml`` 缺失、不可读或格式错误。"""
|
||||
|
||||
|
||||
class MissingAssetError(ContractError):
|
||||
"""A vendor asset declared as required in a manifest is not on disk."""
|
||||
"""清单中声明为必需的厂商物料不在磁盘上。"""
|
||||
|
||||
|
||||
class MissingConfigError(ContractError):
|
||||
"""``config/site.yaml`` does not supply a required declared config key."""
|
||||
"""``config/site.yaml`` 未提供某个必需的已声明配置键。"""
|
||||
|
||||
|
||||
class CapabilityError(ContractError):
|
||||
"""An upper layer asked for a capability the selected driver does not declare."""
|
||||
"""上层请求了所选驱动未声明的能力。"""
|
||||
|
||||
|
||||
class BadConfigValueError(ContractError):
|
||||
"""A config key exists but its value has the wrong type or shape."""
|
||||
"""某个配置键存在,但值的类型或结构不对。"""
|
||||
|
||||
|
||||
def format_error(err: ContractError) -> str:
|
||||
"""Render a contract error as a multi-line, human-actionable message."""
|
||||
"""把契约错误渲染成多行、可执行的中文提示。"""
|
||||
lines = [f"设备契约校验失败: {err.what}"]
|
||||
if err.detail:
|
||||
for line in str(err.detail).splitlines():
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
"""Load the whole device stack and check flows against it.
|
||||
"""加载整个设备栈,并据此校验各流程。
|
||||
|
||||
This is the single entry point the rest of the framework uses. It chains:
|
||||
这是框架其余部分使用的唯一入口。它依次串联:
|
||||
|
||||
1. :mod:`arm_interfaces.registry` — which brand implements each kind
|
||||
2. :mod:`arm_interfaces.manifest` — that brand's capabilities, assets, config
|
||||
3. :mod:`arm_interfaces.config_guard` — does ``site.yaml`` supply that config
|
||||
1. :mod:`arm_interfaces.registry` —— 每一类设备由哪个品牌实现
|
||||
2. :mod:`arm_interfaces.manifest` —— 该品牌的能力、物料、配置
|
||||
3. :mod:`arm_interfaces.config_guard` —— ``site.yaml`` 是否提供了那份配置
|
||||
|
||||
then exposes :func:`check_requirement`, which verifies that a named flow
|
||||
(tracking, hand-eye, gauge assembly, ...) only asks for capabilities the
|
||||
selected drivers actually declare.
|
||||
然后暴露 :func:`check_requirement`,用来验证某个具名流程
|
||||
(跟踪、手眼、通止规装配……)只索要所选驱动确实声明了的能力。
|
||||
|
||||
The payoff is at startup: instead of discovering on the first motion that the
|
||||
selected gripper cannot rotate, the run refuses to start and names the missing
|
||||
capability, the interface it implies, and which manifest to change.
|
||||
收益体现在启动时:不会等到第一次运动才发现所选夹爪不能旋转,而是直接
|
||||
拒绝启动,并点出缺失的能力、它对应的接口,以及该改哪个清单。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -29,31 +27,31 @@ from .registry import REQUIRED_KINDS, Registry, load_registry
|
||||
|
||||
|
||||
class DeviceStack(NamedTuple):
|
||||
"""Everything about the currently selected hardware."""
|
||||
"""当前所选硬件的全部信息。"""
|
||||
|
||||
registry: Registry
|
||||
manifests: Dict[str, Manifest]
|
||||
"""Registry kind -> loaded manifest, e.g. ``{"robots": <Manifest>}``."""
|
||||
"""选型类别 -> 已加载的清单,例如 ``{"robots": <Manifest>}``。"""
|
||||
|
||||
config: Dict[str, List[ResolvedValue]]
|
||||
"""``f"{kind}.{field.key}"`` -> resolved site.yaml values."""
|
||||
"""``f"{kind}.{field.key}"`` -> 解析出的 site.yaml 值。"""
|
||||
|
||||
site_path: Path
|
||||
"""The ``config/site.yaml`` that was validated."""
|
||||
"""被校验过的 ``config/site.yaml``。"""
|
||||
|
||||
def manifest(self, kind: str) -> Manifest:
|
||||
"""Return the manifest for a registry kind."""
|
||||
"""返回某个选型类别的清单。"""
|
||||
return self.manifests[kind]
|
||||
|
||||
def capabilities(self, kind: str) -> Set[str]:
|
||||
"""Effective capabilities of the selected driver for a kind."""
|
||||
"""某类设备所选驱动的有效能力。"""
|
||||
manifest = self.manifests.get(kind)
|
||||
if manifest is None:
|
||||
return set()
|
||||
return set(effective_capabilities(manifest, self._selected_models(kind)))
|
||||
|
||||
def _selected_models(self, kind: str) -> List[str]:
|
||||
"""Model ids selected in site.yaml for a kind (may be empty)."""
|
||||
"""某类设备在 site.yaml 中选中的型号 id(可能为空)。"""
|
||||
manifest = self.manifests.get(kind)
|
||||
if manifest is None:
|
||||
return []
|
||||
@@ -70,12 +68,12 @@ class DeviceStack(NamedTuple):
|
||||
def effective_capabilities(
|
||||
manifest: Manifest, selected_models: Sequence[str] = ()
|
||||
) -> Set[str]:
|
||||
"""Capabilities a driver provides, given which of its models are selected.
|
||||
"""在给定选中哪些型号的前提下,驱动提供的能力。
|
||||
|
||||
Top-level ``capabilities`` are what *every* model of the brand supports.
|
||||
A model may add more (RGI adds ``rotate`` on top of the shared open/close).
|
||||
Declared capabilities are never removed, because narrowing silently would
|
||||
turn a clear startup error into a confusing runtime failure.
|
||||
顶层 ``capabilities`` 是该品牌*每一个*型号都支持的能力。某个型号可以
|
||||
追加更多(RGI 在共有的 open/close 之上增加了 ``rotate``)。已声明的
|
||||
能力绝不会被移除,因为悄悄收窄会把清晰的启动报错变成令人困惑的运行时
|
||||
失败。
|
||||
"""
|
||||
caps: Set[str] = set(manifest.capabilities)
|
||||
for model_id in selected_models:
|
||||
@@ -91,19 +89,16 @@ def load_device_stack(
|
||||
*,
|
||||
verify_assets: bool = True,
|
||||
) -> DeviceStack:
|
||||
"""Load and validate the entire device stack.
|
||||
"""加载并校验整个设备栈。
|
||||
|
||||
Validation failures across *all* device kinds are collected and reported
|
||||
together. Reporting only the first one would force the operator to re-run
|
||||
the check once per missing key, which is exactly the kind of round-trip this
|
||||
layer exists to eliminate.
|
||||
*所有*设备类别的校验失败都会被收集起来一并报告。只报告第一个,会迫使
|
||||
操作员每修一个缺失的键就要重跑一次检查,而这种来回折腾正是本层要消除的。
|
||||
|
||||
Raises
|
||||
------
|
||||
ContractError subclass
|
||||
On contract violations, with actionable fix hints. When more than one
|
||||
kind fails, a single aggregated :class:`ContractError` is raised whose
|
||||
detail lists every problem.
|
||||
ContractError 子类
|
||||
出现契约违规时抛出,并附带可执行的修复提示。当不止一类设备失败时,
|
||||
会抛出单个聚合的 :class:`ContractError`,其 detail 列出全部问题。
|
||||
"""
|
||||
registry = load_registry(registry_path)
|
||||
site_file = site_file or paths.site_path()
|
||||
@@ -114,7 +109,7 @@ def load_device_stack(
|
||||
problems: List[ContractError] = []
|
||||
|
||||
for kind in (*REQUIRED_KINDS, "sim"):
|
||||
# A kind that was not selected at all is optional unless required.
|
||||
# 完全没有选型的类别,除非是必需类别,否则可选。
|
||||
if kind not in registry.selections:
|
||||
continue
|
||||
|
||||
@@ -156,11 +151,10 @@ def load_device_stack(
|
||||
|
||||
|
||||
def _aggregate(problems: List[ContractError]) -> ContractError:
|
||||
"""Combine several contract errors into one reportable error.
|
||||
"""把多个契约错误合并成一个可上报的错误。
|
||||
|
||||
A single problem is returned unchanged so the common case keeps its focused
|
||||
message. Multiple problems are merged, preserving each one's own detail and
|
||||
fix hint.
|
||||
只有单个问题时原样返回,让常见情形保持聚焦的消息。多个问题则合并,
|
||||
并保留每个问题各自的 detail 与修复提示。
|
||||
"""
|
||||
if len(problems) == 1:
|
||||
return problems[0]
|
||||
@@ -188,16 +182,15 @@ def _aggregate(problems: List[ContractError]) -> ContractError:
|
||||
def check_requirement(
|
||||
stack: DeviceStack, requirement_name: str
|
||||
) -> Dict[str, Set[str]]:
|
||||
"""Verify a flow's capability needs are met by the selected drivers.
|
||||
"""验证某个流程的能力需求是否被所选驱动满足。
|
||||
|
||||
Returns the capabilities that were satisfied, grouped by kind, so callers
|
||||
can log what the flow is running against.
|
||||
返回已满足的能力,按类别分组,便于调用方记录该流程运行在什么之上。
|
||||
|
||||
Raises
|
||||
------
|
||||
CapabilityError
|
||||
When a needed capability is missing. The message names the capability,
|
||||
the interface it maps to, the selected brand, and the manifest to edit.
|
||||
缺少某项所需能力时抛出。消息会点出该能力、它映射到的接口、所选的
|
||||
品牌,以及该编辑的清单。
|
||||
"""
|
||||
requirement = REQUIREMENTS.get(requirement_name)
|
||||
if requirement is None:
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
"""Load and validate a brand's ``manifest.yaml``.
|
||||
"""加载并校验某个品牌的 ``manifest.yaml``。
|
||||
|
||||
A manifest is a brand's self-description. It answers four questions:
|
||||
清单是品牌的自我介绍,回答四个问题:
|
||||
|
||||
1. **Who am I?** — ``id``, ``label``, ``vendor``, ``version``
|
||||
2. **What vendor material must be on disk?** — ``assets`` (checked, with the
|
||||
required format and naming convention recorded so operators can comply)
|
||||
3. **What can I do?** — ``capabilities`` (subset of the vocabulary in
|
||||
:mod:`arm_interfaces.capabilities`)
|
||||
4. **What config do I need?** — ``config_schema``, whose ``site_path`` entries
|
||||
point into ``config/site.yaml``
|
||||
1. **我是谁?** —— ``id``、``label``、``vendor``、``version``
|
||||
2. **磁盘上必须有哪些厂商物料?** —— ``assets``(会被检查,并记录必需的
|
||||
格式与命名约定,方便操作员照做)
|
||||
3. **我能做什么?** —— ``capabilities``(:mod:`arm_interfaces.capabilities`
|
||||
词表的子集)
|
||||
4. **我需要什么配置?** —— ``config_schema``,其中每项 ``site_path`` 都指向
|
||||
``config/site.yaml`` 里的位置
|
||||
|
||||
Validation here is intentionally strict: a manifest that declares an unknown
|
||||
capability or a malformed config entry is a bug that should surface at startup,
|
||||
not a runtime surprise mid-motion.
|
||||
这里的校验刻意从严:清单声明了未知能力或格式错误的配置项,属于应当在启动
|
||||
时暴露的 bug,而不是运动中途才冒出来的运行时意外。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,7 +26,7 @@ from .capabilities import ALL_CAPABILITIES, capabilities_for_kind
|
||||
from .errors import ManifestError, MissingAssetError
|
||||
from .registry import KIND_FOLDERS
|
||||
|
||||
#: registry key (plural) -> manifest ``kind`` value (singular).
|
||||
#: 选型中的键(复数)-> 清单里 ``kind`` 的取值(单数)。
|
||||
KIND_SINGULAR: Dict[str, str] = {
|
||||
"robots": "robot",
|
||||
"grippers": "gripper",
|
||||
@@ -36,15 +35,15 @@ KIND_SINGULAR: Dict[str, str] = {
|
||||
"sim": "sim",
|
||||
}
|
||||
|
||||
#: Config value types the contract layer knows how to check.
|
||||
#: 契约层知道如何检查的配置值类型。
|
||||
KNOWN_TYPES = {"str", "int", "float", "bool", "list", "dict"}
|
||||
|
||||
#: Manifest top-level keys that must be present.
|
||||
#: 清单中必须存在的顶层键。
|
||||
REQUIRED_KEYS = ("id", "label", "kind", "driver")
|
||||
|
||||
|
||||
class Asset(NamedTuple):
|
||||
"""One piece of vendor material a manifest expects on disk."""
|
||||
"""清单期望磁盘上存在的一项厂商物料。"""
|
||||
|
||||
path: str
|
||||
required: bool
|
||||
@@ -56,7 +55,7 @@ class Asset(NamedTuple):
|
||||
|
||||
|
||||
class DriverSpec(NamedTuple):
|
||||
"""How to start this brand's adapter."""
|
||||
"""如何启动该品牌的适配器。"""
|
||||
|
||||
package: str
|
||||
executable: str
|
||||
@@ -65,7 +64,7 @@ class DriverSpec(NamedTuple):
|
||||
|
||||
|
||||
class ConfigField(NamedTuple):
|
||||
"""One declared config key, pointing into ``config/site.yaml``."""
|
||||
"""一个已声明的配置键,指向 ``config/site.yaml`` 中的位置。"""
|
||||
|
||||
key: str
|
||||
site_path: str
|
||||
@@ -77,7 +76,7 @@ class ConfigField(NamedTuple):
|
||||
|
||||
|
||||
class Manifest(NamedTuple):
|
||||
"""Parsed and validated ``manifest.yaml``."""
|
||||
"""已解析并校验的 ``manifest.yaml``。"""
|
||||
|
||||
id: str
|
||||
label: str
|
||||
@@ -94,14 +93,14 @@ class Manifest(NamedTuple):
|
||||
raw: Dict[str, Any]
|
||||
|
||||
def supports(self, capability: str) -> bool:
|
||||
"""Whether this driver declares the capability."""
|
||||
"""该驱动是否声明了这项能力。"""
|
||||
return capability in self.capabilities
|
||||
|
||||
def model_capabilities(self, model_id: str) -> Optional[Tuple[str, ...]]:
|
||||
"""Capabilities for a specific model id, if the manifest lists models.
|
||||
"""某个具体型号 id 的能力(前提是清单列出了型号)。
|
||||
|
||||
Returns ``None`` when the manifest does not enumerate models, which
|
||||
means the top-level ``capabilities`` list applies to all of them.
|
||||
清单未逐一列举型号时返回 ``None``,表示顶层 ``capabilities`` 列表
|
||||
对全部型号都适用。
|
||||
"""
|
||||
for model in self.models:
|
||||
if model.get("id") == model_id:
|
||||
@@ -113,19 +112,19 @@ class Manifest(NamedTuple):
|
||||
|
||||
|
||||
def manifest_path(kind: str, brand: str) -> Path:
|
||||
"""Return the expected manifest path for a kind/brand pair."""
|
||||
"""返回某个“类别/品牌”组合预期的清单路径。"""
|
||||
return paths.brand_dir(KIND_FOLDERS.get(kind, kind), brand) / "manifest.yaml"
|
||||
|
||||
|
||||
def load_manifest(kind: str, brand: str, path: Path | None = None) -> Manifest:
|
||||
"""Read, validate and return the manifest for a kind/brand pair.
|
||||
"""读取、校验并返回某个“类别/品牌”组合的清单。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
kind:
|
||||
Registry key — ``robots``, ``grippers``, ``sensors``, ``cameras``, ``sim``.
|
||||
选型中的键 —— ``robots``、``grippers``、``sensors``、``cameras``、``sim``。
|
||||
brand:
|
||||
Brand folder name under ``devices/<kind>/``.
|
||||
``devices/<kind>/`` 下的品牌目录名。
|
||||
"""
|
||||
target = path or manifest_path(kind, brand)
|
||||
|
||||
@@ -187,11 +186,10 @@ def load_manifest(kind: str, brand: str, path: Path | None = None) -> Manifest:
|
||||
|
||||
|
||||
def check_assets(manifest: Manifest, brand: str, kind: str) -> None:
|
||||
"""Verify every ``required`` asset exists, with format hints in the error.
|
||||
"""校验每个 ``required`` 物料都存在,并在错误里给出格式提示。
|
||||
|
||||
Only presence is checked. Format and naming cannot be verified
|
||||
automatically, so they are reported back to the operator in the failure
|
||||
message to make compliance possible.
|
||||
这里只检查是否存在。格式与命名无法自动验证,因此在失败消息里回告给
|
||||
操作员,让照做成为可能。
|
||||
"""
|
||||
base = paths.brand_dir(KIND_FOLDERS.get(kind, kind), brand)
|
||||
missing: List[str] = []
|
||||
@@ -225,7 +223,7 @@ def check_assets(manifest: Manifest, brand: str, kind: str) -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# internal helpers
|
||||
# 内部辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _available_brands(kind: str) -> List[str]:
|
||||
@@ -236,10 +234,10 @@ def _available_brands(kind: str) -> List[str]:
|
||||
|
||||
|
||||
def _asset_present(target: Path) -> bool:
|
||||
"""An asset counts as present if it is a file, a dir, or a symlink to either.
|
||||
"""物料是文件、目录,或指向二者的符号链接,都算作存在。
|
||||
|
||||
A directory is accepted because vendors often ship a tree (``rdk/``) and the
|
||||
operator should not be forced to add a sentinel file inside it.
|
||||
接受目录,是因为厂商常以整棵目录树(``rdk/``)交付,不应该逼着操作员
|
||||
在目录里额外放一个占位文件。
|
||||
"""
|
||||
if target.is_symlink():
|
||||
return target.exists()
|
||||
@@ -298,7 +296,7 @@ def _parse_capabilities(
|
||||
detail=f"该清单里合法的能力: {', '.join(known_code) or '(无)'}",
|
||||
)
|
||||
|
||||
# A capability declared twice is almost always a copy-paste slip.
|
||||
# 同一项能力声明两次,几乎总是复制粘贴出的疏漏。
|
||||
duplicates = sorted({c for c in raw if raw.count(c) > 1})
|
||||
if duplicates:
|
||||
raise ManifestError(
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""Single source of truth for workspace paths.
|
||||
"""工作区路径的唯一权威来源。
|
||||
|
||||
The old codebase resolved the workspace root in three different ways:
|
||||
``arm_application/site_io.py``, ``scripts/lib/export_site_env.py``, and several
|
||||
nodes that read ``site.yaml`` directly with their own ``_site_path()`` helpers.
|
||||
They did not always agree. This module is the one place that resolves paths, and
|
||||
everything else — including ``site_io.py`` — should delegate here.
|
||||
旧代码库用三种不同方式解析工作区根目录:
|
||||
``arm_application/site_io.py``、``scripts/lib/export_site_env.py``,以及若干
|
||||
用各自 ``_site_path()`` 辅助函数直接读 ``site.yaml`` 的节点。
|
||||
它们并不总是一致。本模块是解析路径的唯一场所,其他所有地方
|
||||
——包括 ``site_io.py``——都应委托到这里。
|
||||
|
||||
Resolution order (first non-empty wins):
|
||||
解析顺序(第一个非空者胜出):
|
||||
|
||||
1. Explicit environment override (``SITE_ROS2_ROOT`` / ``SITE_CONFIG``)
|
||||
2. ``ROS2_WS`` (exported by ``scripts/env.sh``)
|
||||
1. 显式环境变量覆盖(``SITE_ROS2_ROOT`` / ``SITE_CONFIG``)
|
||||
2. ``ROS2_WS``(由 ``scripts/env.sh`` 导出)
|
||||
3. ``~/ros2``
|
||||
"""
|
||||
|
||||
@@ -20,7 +20,7 @@ from pathlib import Path
|
||||
|
||||
|
||||
def ros2_root() -> Path:
|
||||
"""Return the workspace root, honouring environment overrides."""
|
||||
"""返回工作区根目录,并遵循环境变量覆盖。"""
|
||||
for key in ("SITE_ROS2_ROOT", "ROS2_WS"):
|
||||
value = os.environ.get(key, "").strip()
|
||||
if value:
|
||||
@@ -29,7 +29,7 @@ def ros2_root() -> Path:
|
||||
|
||||
|
||||
def site_path() -> Path:
|
||||
"""Return the path to the master config ``config/site.yaml``."""
|
||||
"""返回主配置 ``config/site.yaml`` 的路径。"""
|
||||
override = os.environ.get("SITE_CONFIG", "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
@@ -37,7 +37,7 @@ def site_path() -> Path:
|
||||
|
||||
|
||||
def devices_root() -> Path:
|
||||
"""Return the root of the device folder tree."""
|
||||
"""返回设备目录树的根目录。"""
|
||||
override = os.environ.get("SITE_DEVICES_ROOT", "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
@@ -45,19 +45,19 @@ def devices_root() -> Path:
|
||||
|
||||
|
||||
def registry_path() -> Path:
|
||||
"""Return the path to the device selection file ``devices/registry.yaml``."""
|
||||
"""返回设备选型文件 ``devices/registry.yaml`` 的路径。"""
|
||||
return devices_root() / "registry.yaml"
|
||||
|
||||
|
||||
def handeye_root() -> Path:
|
||||
"""Return the root of the hand-eye module directory (``handeye/``)."""
|
||||
"""返回手眼模块目录(``handeye/``)的根目录。"""
|
||||
return ros2_root() / "handeye"
|
||||
|
||||
|
||||
def brand_dir(kind: str, brand: str) -> Path:
|
||||
"""Return the directory holding one brand under a device kind.
|
||||
"""返回某类设备下某个品牌所在的目录。
|
||||
|
||||
``kind`` is the plural folder name used in ``devices/`` (``robots``,
|
||||
``grippers``, ``sensors``, ``cameras``, ``sim``).
|
||||
``kind`` 是 ``devices/`` 中使用的复数目录名(``robots``、
|
||||
``grippers``、``sensors``、``cameras``、``sim``)。
|
||||
"""
|
||||
return devices_root() / kind / brand
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Load and validate ``devices/registry.yaml``.
|
||||
"""加载并校验 ``devices/registry.yaml``。
|
||||
|
||||
The registry answers exactly one question: *which brand implements each device
|
||||
kind right now?* It holds no field values by design — every speed, threshold and
|
||||
serial number lives in ``config/site.yaml``. Keeping selection and values apart
|
||||
is what stops the "I changed the value but nothing happened" class of bug.
|
||||
选型文件只回答一个问题:*当前每一类设备由哪个品牌实现?* 按设计它不保存
|
||||
任何字段值——所有速度、阈值和序列号都在 ``config/site.yaml`` 里。把“选型”
|
||||
与“取值”分开,正是为了杜绝“改了值却毫无反应”这一类 bug。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,8 +15,8 @@ import yaml
|
||||
from . import paths
|
||||
from .errors import RegistryError
|
||||
|
||||
#: Device kind -> the folder under ``devices/`` holding its brands.
|
||||
#: Also the key used in ``registry.yaml``.
|
||||
#: 设备类别 -> ``devices/`` 下存放其品牌的目录。
|
||||
#: 同时也是 ``registry.yaml`` 中使用的键。
|
||||
KIND_FOLDERS: Dict[str, str] = {
|
||||
"robots": "robots",
|
||||
"grippers": "grippers",
|
||||
@@ -26,25 +25,25 @@ KIND_FOLDERS: Dict[str, str] = {
|
||||
"sim": "sim",
|
||||
}
|
||||
|
||||
#: Every kind the registry must select. ``sim`` is optional (headless setups).
|
||||
#: 选型文件必须选择的每一类设备。``sim`` 是可选的(无头环境)。
|
||||
REQUIRED_KINDS = ("robots", "grippers", "sensors", "cameras")
|
||||
OPTIONAL_KINDS = ("sim",)
|
||||
|
||||
|
||||
class Registry(NamedTuple):
|
||||
"""Parsed ``devices/registry.yaml``."""
|
||||
"""解析后的 ``devices/registry.yaml``。"""
|
||||
|
||||
selections: Dict[str, str]
|
||||
"""kind -> brand id, e.g. ``{"robots": "flexiv"}``."""
|
||||
"""kind -> 品牌 id,例如 ``{"robots": "flexiv"}``。"""
|
||||
|
||||
path: Path
|
||||
"""Where the registry was loaded from (for error messages)."""
|
||||
"""选型文件的加载来源(用于错误消息)。"""
|
||||
|
||||
raw: Dict[str, Any]
|
||||
"""Full parsed document, for forward compatibility."""
|
||||
"""完整解析结果,便于向前兼容。"""
|
||||
|
||||
def brand(self, kind: str) -> str:
|
||||
"""Return the selected brand for a kind, raising if unset."""
|
||||
"""返回某类设备选中的品牌,未设置则抛错。"""
|
||||
try:
|
||||
return self.selections[kind]
|
||||
except KeyError:
|
||||
@@ -58,13 +57,12 @@ class Registry(NamedTuple):
|
||||
|
||||
|
||||
def load_registry(path: Path | None = None) -> Registry:
|
||||
"""Read and validate the registry file.
|
||||
"""读取并校验选型文件。
|
||||
|
||||
Raises
|
||||
------
|
||||
RegistryError
|
||||
If the file is missing, unparseable, is not a mapping, or omits a
|
||||
required kind.
|
||||
文件缺失、无法解析、顶层不是映射,或漏掉必需的设备类别时抛出。
|
||||
"""
|
||||
target = path or paths.registry_path()
|
||||
|
||||
@@ -119,7 +117,7 @@ def load_registry(path: Path | None = None) -> Registry:
|
||||
|
||||
|
||||
def list_available_brands(kind: str) -> list[str]:
|
||||
"""Return brand folder names present on disk for a device kind."""
|
||||
"""返回到磁盘上某类设备现存的品牌目录名。"""
|
||||
folder = paths.devices_root() / KIND_FOLDERS.get(kind, kind)
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
|
||||
@@ -4,9 +4,9 @@ script_dir=$base/lib/arm_interfaces
|
||||
install_scripts=$base/lib/arm_interfaces
|
||||
|
||||
[tool:pytest]
|
||||
# ROS 2 Humble's two launch-testing pytest plugins use old pytest hook
|
||||
# signatures that current pytest rejects, which aborts collection before our
|
||||
# tests even run. Disabling them is safe: these are pure-Python unit tests and
|
||||
# use none of launch_testing's fixtures.
|
||||
# ROS 2 Humble 的两个 launch-testing pytest 插件使用的旧版 pytest hook
|
||||
# 签名会被当前 pytest 拒绝,导致测试还没开始就被中止收集。
|
||||
# 禁用它们是安全的:这些是纯 Python 单元测试,
|
||||
# 完全没有用到 launch_testing 的任何 fixture。
|
||||
addopts = -p no:launch_testing -p no:launch_ros
|
||||
testpaths = test
|
||||
|
||||
@@ -18,7 +18,7 @@ setup(
|
||||
license='Apache-2.0',
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
# Validate the whole device stack: registry -> manifests -> site.yaml.
|
||||
# 校验整条设备链:registry → 各 manifest → site.yaml。
|
||||
'device_check = arm_interfaces.cli:main',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""Unit tests for the device contract layer.
|
||||
"""设备契约层的单元测试。
|
||||
|
||||
These build a throwaway workspace in ``tmp_path`` so they never touch the real
|
||||
``devices/`` tree or ``config/site.yaml``. Every failure mode that the layer is
|
||||
supposed to catch has a test asserting the *shape* of the error — in particular
|
||||
that a ``fix_hint`` is present, because a contract error without a remediation
|
||||
instruction is only half the job.
|
||||
这些测试在 ``tmp_path`` 里搭一个用完即弃的工作区,因此绝不会碰真实的
|
||||
``devices/`` 目录树或 ``config/site.yaml``。本层应当捕获的每一种失败模式
|
||||
都有一个测试断言错误的*形态*——尤其是必须带 ``fix_hint``,因为一条没有
|
||||
修复指示的契约错误只算完成了一半工作。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -36,7 +35,7 @@ from arm_interfaces.registry import list_available_brands
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fake workspace construction
|
||||
# 构造假工作区
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MINIMAL_REGISTRY = """\
|
||||
@@ -143,7 +142,7 @@ cameras:
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Dict[str, Path]:
|
||||
"""Create a minimal, valid device workspace and point the layer at it."""
|
||||
"""创建一个最小可用的设备工作区,并让契约层指向它。"""
|
||||
devices = tmp_path / "devices"
|
||||
for kind, brand, manifest in (
|
||||
("robots", "acme", ACME_MANIFEST),
|
||||
@@ -173,7 +172,7 @@ def write(path: Path, content: str) -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# registry
|
||||
# 选型
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRegistry:
|
||||
@@ -217,7 +216,7 @@ class TestRegistry:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# manifest
|
||||
# 清单
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestManifest:
|
||||
@@ -254,7 +253,7 @@ class TestManifest:
|
||||
assert "get_arm_state" in (excinfo.value.fix_hint or "")
|
||||
|
||||
def test_capability_from_another_kind_is_rejected(self, workspace):
|
||||
"""A robot must not be able to claim a gripper capability."""
|
||||
"""机器人绝不能声称自己拥有夹爪能力。"""
|
||||
write(
|
||||
workspace["devices"] / "robots" / "acme" / "manifest.yaml",
|
||||
ACME_MANIFEST.replace(" - get_arm_state", " - rotate\n - get_arm_state"),
|
||||
@@ -276,7 +275,7 @@ class TestManifest:
|
||||
assert "重复" in excinfo.value.what
|
||||
|
||||
def test_required_and_default_together_is_rejected(self, workspace):
|
||||
"""Ambiguous intent: either the user must supply it, or we have a fallback."""
|
||||
"""意图含糊:要么必须由用户提供,要么我们已有兜底值。"""
|
||||
write(
|
||||
workspace["devices"] / "robots" / "acme" / "manifest.yaml",
|
||||
ACME_MANIFEST.replace(
|
||||
@@ -334,7 +333,7 @@ class TestManifest:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# asset presence
|
||||
# 物料是否存在
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAssets:
|
||||
@@ -354,7 +353,7 @@ class TestAssets:
|
||||
with pytest.raises(MissingAssetError) as excinfo:
|
||||
check_assets(loaded, "acme", "robots")
|
||||
message = str(excinfo.value)
|
||||
# The operator must be told how to comply, not merely that it is missing.
|
||||
# 必须告诉操作员该怎么照做,而不只是说“缺了”。
|
||||
assert "厂商原始格式" in message
|
||||
assert "保持原目录名" in message
|
||||
|
||||
@@ -368,7 +367,7 @@ class TestAssets:
|
||||
write(workspace["devices"] / "robots" / "acme" / "manifest.yaml", manifest)
|
||||
loaded = load_manifest("robots", "acme")
|
||||
(workspace["devices"] / "robots" / "acme" / "rdk").mkdir()
|
||||
check_assets(loaded, "acme", "robots") # must not raise
|
||||
check_assets(loaded, "acme", "robots") # 不应抛错
|
||||
|
||||
def test_optional_asset_may_be_absent(self, workspace):
|
||||
manifest = ACME_MANIFEST + textwrap.dedent("""\
|
||||
@@ -379,11 +378,11 @@ class TestAssets:
|
||||
""")
|
||||
write(workspace["devices"] / "robots" / "acme" / "manifest.yaml", manifest)
|
||||
loaded = load_manifest("robots", "acme")
|
||||
check_assets(loaded, "acme", "robots") # must not raise
|
||||
check_assets(loaded, "acme", "robots") # 不应抛错
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# config_guard: path resolution and type checking
|
||||
# config_guard:路径解析与类型检查
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolvePath:
|
||||
@@ -400,7 +399,7 @@ class TestResolvePath:
|
||||
assert found[1].path == "robot.robots[1].sn"
|
||||
|
||||
def test_mapping_expansion(self):
|
||||
"""site.yaml registers cameras as a mapping keyed by name, not a sequence."""
|
||||
"""site.yaml 把相机登记为以名称作键的映射,而不是序列。"""
|
||||
site = {"cameras": {"left": {"serial": "1"}, "right": {"serial": "2"}}}
|
||||
found = resolve_path(site, "cameras[].serial")
|
||||
assert [f.value for f in found] == ["1", "2"]
|
||||
@@ -410,7 +409,7 @@ class TestResolvePath:
|
||||
assert resolve_path({"a": {}}, "a.b.c") == []
|
||||
|
||||
def test_scalar_where_collection_expected_is_kept_for_type_check(self):
|
||||
"""Better to report a type error than to silently claim 'missing'."""
|
||||
"""报告类型错误,总好过静默声称“缺失”。"""
|
||||
found = resolve_path({"cameras": 5}, "cameras[].serial")
|
||||
assert len(found) == 1
|
||||
assert found[0].value == 5
|
||||
@@ -418,10 +417,10 @@ class TestResolvePath:
|
||||
|
||||
class TestCheckConfig:
|
||||
def _manifest_with(self, workspace, extra: str):
|
||||
"""Append extra ``config_schema`` entries to the Acme manifest.
|
||||
"""把额外的 ``config_schema`` 条目追加到 Acme 清单上。
|
||||
|
||||
``extra`` is written flush-left and indented here, so call sites can
|
||||
indent a YAML snippet naturally without counting spaces.
|
||||
``extra`` 以顶格书写后在这里统一缩进,因此调用处可以自然地为 YAML
|
||||
片段缩进,而无需数空格。
|
||||
"""
|
||||
body = textwrap.indent(textwrap.dedent(extra), " ")
|
||||
write(workspace["devices"] / "robots" / "acme" / "manifest.yaml", ACME_MANIFEST + body)
|
||||
@@ -483,7 +482,7 @@ class TestCheckConfig:
|
||||
assert "fast" in (excinfo.value.detail or "")
|
||||
|
||||
def test_bool_is_not_accepted_as_float(self, workspace):
|
||||
"""YAML ``true`` must not be silently used as 1.0."""
|
||||
"""YAML 里的 ``true`` 绝不能被悄悄当作 1.0 使用。"""
|
||||
manifest = self._manifest_with(
|
||||
workspace,
|
||||
"""\
|
||||
@@ -499,7 +498,7 @@ class TestCheckConfig:
|
||||
check_config(manifest, site)
|
||||
|
||||
def test_every_instance_must_supply_per_instance_value(self, workspace):
|
||||
"""robot.robots[].sn requires all arms to be configured."""
|
||||
"""robot.robots[].sn 要求所有机械臂都完成配置。"""
|
||||
write(
|
||||
workspace["devices"] / "robots" / "acme" / "manifest.yaml",
|
||||
ACME_MANIFEST.replace("site_path: robot.sn", "site_path: robot.robots[].sn"),
|
||||
@@ -509,12 +508,12 @@ class TestCheckConfig:
|
||||
site["robot"]["robots"] = [{"sn": "A"}, {"sn": ""}]
|
||||
with pytest.raises(MissingConfigError) as excinfo:
|
||||
check_config(manifest, site)
|
||||
# The offending instance must be identified by index.
|
||||
# 出问题的那个实例必须按索引定位到。
|
||||
assert "robots[1].sn" in str(excinfo.value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# loader: aggregation and capabilities
|
||||
# loader:聚合与能力
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLoader:
|
||||
@@ -525,9 +524,9 @@ class TestLoader:
|
||||
assert stack.capabilities("robots") == {"get_arm_state", "follow_joint_trajectory"}
|
||||
|
||||
def test_all_problems_reported_at_once(self, workspace):
|
||||
"""One run must surface every broken kind, not just the first."""
|
||||
"""一次运行必须暴露出每一类坏掉的设备,而不只是第一个。"""
|
||||
site = workspace["site"]
|
||||
write(site, "cameras:\n cam_a:\n serial: '1'\n") # robot.sn etc. missing
|
||||
write(site, "cameras:\n cam_a:\n serial: '1'\n") # robot.sn 等缺失
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
load_device_stack()
|
||||
message = str(excinfo.value)
|
||||
@@ -545,7 +544,7 @@ class TestLoader:
|
||||
write(workspace["devices"] / "robots" / "acme" / "manifest.yaml", manifest)
|
||||
with pytest.raises(MissingAssetError):
|
||||
load_device_stack()
|
||||
load_device_stack(verify_assets=False) # must not raise
|
||||
load_device_stack(verify_assets=False) # 不应抛错
|
||||
|
||||
|
||||
class TestEffectiveCapabilities:
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Integration tests against the real workspace.
|
||||
"""针对真实工作区的集成测试。
|
||||
|
||||
These are the tests that keep the shipped configuration honest. They load the
|
||||
actual ``devices/`` tree and the actual ``config/site.yaml``, so a bad edit to
|
||||
either fails here rather than on the robot.
|
||||
这些测试用来让随仓库交付的配置保持诚实。它们加载真实的 ``devices/`` 目录树
|
||||
和真实的 ``config/site.yaml``,所以对其中任何一处的糟糕改动都会在这里失败,
|
||||
而不是在机器人上失败。
|
||||
|
||||
They are skipped when the real workspace is not present (for example when the
|
||||
package is built in isolation), so they never turn into false failures.
|
||||
真实工作区不存在时会跳过(例如包被单独构建时),因此它们绝不会变成假失败。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,26 +30,26 @@ pytestmark = pytest.mark.skipif(
|
||||
|
||||
class TestRealWorkspace:
|
||||
def test_contract_satisfied(self, monkeypatch):
|
||||
"""The shipped device tree and site.yaml must pass validation."""
|
||||
# Guard against a leftover SITE_* override from another test leaking in.
|
||||
"""随仓库交付的设备目录树和 site.yaml 必须通过校验。"""
|
||||
# 防止另一个测试遗留的 SITE_* 覆盖泄漏进来。
|
||||
for var in ("SITE_DEVICES_ROOT", "SITE_CONFIG", "SITE_ROS2_ROOT"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
stack = load_device_stack()
|
||||
|
||||
# Every required kind is present and selected.
|
||||
# 每个必需的设备类别都已存在并完成选型。
|
||||
for kind in ("robots", "grippers", "sensors", "cameras"):
|
||||
assert stack.manifest(kind) is not None, f"{kind} 未选型"
|
||||
|
||||
def test_selected_models_resolve(self, monkeypatch):
|
||||
"""Model ids named in site.yaml must exist in the corresponding manifest."""
|
||||
"""site.yaml 中写明的型号 id 必须存在于对应的清单里。"""
|
||||
for var in ("SITE_DEVICES_ROOT", "SITE_CONFIG"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
stack = load_device_stack()
|
||||
site = load_site()
|
||||
|
||||
# Cameras: every entry's model must be a declared model id.
|
||||
# 相机:每一项的 model 都必须是清单声明的型号 id。
|
||||
camera_manifest = stack.manifest("cameras")
|
||||
declared = {model["id"] for model in camera_manifest.models}
|
||||
for name, camera in (site.get("cameras") or {}).items():
|
||||
@@ -59,7 +58,7 @@ class TestRealWorkspace:
|
||||
f"{camera_manifest.path} 声明的型号 {sorted(declared)} 里"
|
||||
)
|
||||
|
||||
# Grippers: same check against the gripper manifest's models.
|
||||
# 夹爪:对夹爪清单的型号做同样的检查。
|
||||
gripper_manifest = stack.manifest("grippers")
|
||||
gripper_models = {model["id"] for model in gripper_manifest.models}
|
||||
for index, robot in enumerate((site.get("robot") or {}).get("robots") or []):
|
||||
@@ -72,7 +71,7 @@ class TestRealWorkspace:
|
||||
)
|
||||
|
||||
def test_sensor_models_folder_matches_config(self, monkeypatch):
|
||||
"""tactile.model must match a subdirectory under sensors/orisys/models/."""
|
||||
"""tactile.model 必须与 sensors/orisys/models/ 下的某个子目录对应。"""
|
||||
for var in ("SITE_DEVICES_ROOT", "SITE_CONFIG"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
@@ -95,7 +94,7 @@ class TestRealWorkspace:
|
||||
)
|
||||
|
||||
def test_assembly_requirement_met_by_real_selection(self, monkeypatch):
|
||||
"""The gauge assembly flow must be satisfiable with shipped hardware."""
|
||||
"""随仓库交付的硬件必须能满足通止规装配流程。"""
|
||||
for var in ("SITE_DEVICES_ROOT", "SITE_CONFIG"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
@@ -115,13 +114,11 @@ class TestRealWorkspace:
|
||||
|
||||
|
||||
class TestExampleParity:
|
||||
"""``site.example.yaml`` is generated from ``site.yaml``; it must stay in sync.
|
||||
"""``site.example.yaml`` 由 ``site.yaml`` 生成,必须保持同步。
|
||||
|
||||
The template used to be maintained by hand and had drifted 114 keys behind
|
||||
the live config, so a fresh deployment following it would silently miss
|
||||
``servo.*``, ``hole_detect.stage2.*`` and ``sim.*``. It is now generated by
|
||||
``scripts/gen_site_example.py``, and this test fails the moment the two
|
||||
diverge.
|
||||
这份模板以前靠手工维护,曾比现场配置落后 114 个键,导致照着它做的新部署
|
||||
会静默漏掉 ``servo.*``、``hole_detect.stage2.*`` 和 ``sim.*``。现在它由
|
||||
``scripts/gen_site_example.py`` 生成,两者一旦分叉,本测试立即失败。
|
||||
"""
|
||||
|
||||
def _example_path(self) -> Path:
|
||||
@@ -144,7 +141,7 @@ class TestExampleParity:
|
||||
)
|
||||
|
||||
def test_example_contains_no_live_values(self):
|
||||
"""The template must not leak serial numbers, IPs or user paths."""
|
||||
"""模板绝不能泄漏序列号、IP 或用户路径。"""
|
||||
example = self._example_path()
|
||||
if not example.is_file():
|
||||
pytest.skip("config/site.example.yaml 不存在")
|
||||
|
||||
Reference in New Issue
Block a user