pyflowx.yaml_loader 源代码

"""YAML 任务编排加载器.

参考 GitHub Actions 风格, 从 YAML 文件构建任务图.
支持 jobs/needs/strategy.matrix/if 等核心 CI/CD 概念, 实现声明式串并行任务编排.

Schema 概览
-----------
::

    strategy: thread              # 图级默认策略
    defaults:                     # 图级默认值
      retry: {max_attempts: 3}
      verbose: true

    jobs:
      setup:                      # job_id 即任务名
        run: "git clone https://github.com/foo/bar"

      build:
        needs: [setup]            # 依赖列表
        cmd: ["python", "-m", "build"]
        strategy:                 # 矩阵扇出
          matrix:
            python: ["3.8", "3.9"]
        if: "success()"           # 条件
        timeout: 300
        retry: {max_attempts: 2, delay: 1.0}
        cwd: ./build
        env: {DEBUG: "1"}
        verbose: true
        continue-on-error: false
        tags: [ci]
        runs-on: linux
        priority: 10
        concurrency-key: deploy_lock

      deploy:
        needs: [build]
        run: "twine upload dist/*"
        if: "env.DEPLOY_TOKEN != ''"
        allow-upstream-skip: true

字段映射
-------
- ``cmd`` / ``run``: ``cmd`` 为命令列表, ``run`` 为 shell 字符串
- ``fn``: 引用 ``register_fn`` 注册的 Python 函数名, 配合 ``args``/``kwargs`` 传参
- ``needs``: 对应 ``depends_on``
- ``if``: 条件表达式, 支持 ``success()`` / ``always()`` / ``env.VAR`` / ``env.VAR == 'x'``
- ``strategy.matrix``: 笛卡尔积展开为多个任务, 任务名追加 ``_{key}-{val}`` 后缀
- ``runs-on``: 追加到 ``tags``
- 其余字段名连字符转下划线后透传给 :class:`~pyflowx.task.TaskSpec`

矩阵占位符
----------
矩阵值通过 ``${{ matrix.key }}`` 占位符注入 ``cmd`` / ``run`` / ``cwd`` / ``env`` / ``args`` / ``kwargs`` 中::

    jobs:
      test:
        cmd: ["python{{ matrix.version }}", "-m", "pytest"]
        strategy:
          matrix:
            version: ["3.8", "3.9"]

变量占位符
----------
运行时变量通过 ``${VAR}`` 占位符注入, 变量由 ``load_yaml(path, variables={...})``
或 ``parse_yaml_string(content, variables={...})`` 传入::

    # Python
    graph = px.load_yaml("pipeline.yaml", variables={"PROJECT_DIR": "./project"})

    # YAML
    jobs:
      pack:
        fn: pack_source
        args: ["${PROJECT_DIR}", "./dist"]

函数引用
----------
``fn`` 字段引用 ``@px.register_fn`` 注册的函数, 配合 ``args``/``kwargs`` 传参::

    # Python
    @px.register_fn("pack_source")
    def pack_source(project_dir, output_dir):
        ...

    # YAML
    jobs:
      pack:
        fn: pack_source
        args: ["./project", "./dist"]
        needs: [clean]
"""

from __future__ import annotations

__all__ = ["YamlLoadError", "build_cli_parser", "load_yaml", "parse_yaml_string", "run_cli", "run_yaml"]

import itertools
import re
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any, Iterable, Mapping, Sequence, cast

import pyflowx as px
from pyflowx.conditions import BuiltinConditions
from pyflowx.executors import Strategy
from pyflowx.registry import get_fn as _get_fn
from pyflowx.task import Condition, RetryPolicy, TaskSpec

if TYPE_CHECKING:
    from pyflowx.executors import EventCallback, RunReport
    from pyflowx.storage import StateBackend

# 导入 ops 模块以注册 CLI 工具函数, 使 YAML fn 字段可引用
# 使用 try/except 避免最小安装场景下的导入失败
try:
    from pyflowx.ops import dev as _dev  # noqa: F401
    from pyflowx.ops import files as _files  # noqa: F401
    from pyflowx.ops import media as _media  # noqa: F401
    from pyflowx.ops import system as _system  # noqa: F401
except ImportError:  # pragma: no cover
    pass

# 矩阵占位符: ${{ matrix.key }}
_MATRIX_PLACEHOLDER = re.compile(r"\$\{\{\s*matrix\.(\w+)\s*\}\}")

# 变量占位符: ${VAR}
_VARIABLE_PLACEHOLDER = re.compile(r"\$\{(\w+)\}")

# 变量全匹配: 字符串完全等于 ${VAR} (用于返回变量实际值而非字符串化)
_VARIABLE_FULL_MATCH = re.compile(r"^\$\{(\w+)\}$")

# 条件表达式
_ENV_VAR_EXISTS_RE = re.compile(r"^env\.(\w+)$")
_ENV_VAR_EQUALS_RE = re.compile(r"^env\.(\w+)\s*==\s*['\"]([^'\"]*)['\"]$")
_ENV_VAR_NOT_EQUALS_RE = re.compile(r"^env\.(\w+)\s*!=\s*['\"]([^'\"]*)['\"]$")

# 支持的 job 字段名(连字符形式)
_JOB_FIELDS_HYPHEN = frozenset(
    {
        "continue-on-error",
        "skip-if-missing",
        "allow-upstream-skip",
        "concurrency-key",
        "runs-on",
    }
)


class YamlLoadError(px.PyFlowXError):
    """YAML 加载错误."""


def _aggregate_noop() -> None:
    """聚合 job 的占位函数.

    方向 B: 有 ``needs`` 无 ``cmd/run/fn`` 的 job 仅作为依赖聚合点,
    执行时调用此 no-op 函数, 不产生副作用, 返回 ``None``.
    """
    return None


[文档] def load_yaml(path: str | Path, variables: Mapping[str, Any] | None = None) -> px.Graph: """从 YAML 文件加载任务图. Parameters ---------- path : str | Path YAML 文件路径 variables : Mapping[str, Any] | None 运行时变量, 用于替换 ``${VAR}`` 占位符 Returns ------- px.Graph 构建好的任务图 Raises ------ YamlLoadError 文件不存在、YAML 格式错误、schema 校验失败、循环依赖等 """ p = Path(path) if not p.exists(): raise YamlLoadError(f"YAML 文件不存在: {p}") try: content = p.read_text(encoding="utf-8") except OSError as e: raise YamlLoadError(f"读取文件失败: {e}") from e return parse_yaml_string(content, variables=variables)
[文档] def parse_yaml_string(content: str, variables: Mapping[str, Any] | None = None) -> px.Graph: """从 YAML 字符串构建任务图. Parameters ---------- content : str YAML 文本 variables : Mapping[str, Any] | None 运行时变量, 用于替换 ``${VAR}`` 占位符 Returns ------- px.Graph 构建好的任务图 """ data = _parse_yaml(content) if not isinstance(data, Mapping): raise YamlLoadError("YAML 根节点必须是 mapping (对象)") return _build_graph(data, variables=variables)
[文档] def run_yaml( path: str | Path, jobs: str | Sequence[str] | None = None, variables: Mapping[str, Any] | None = None, strategy: str | None = None, *, max_workers: int | None = None, dry_run: bool = False, verbose: bool = False, on_event: EventCallback | None = None, state: StateBackend | None = None, concurrency_limits: Mapping[str, int] | None = None, ) -> RunReport: """加载 YAML 配置并执行任务图. 便捷函数, 组合 :func:`load_yaml` + (可选) 子图选择 + :func:`pyflowx.run`. 指定 ``jobs`` 时自动包含其传递依赖, 适用于从多 job 配置中执行单个命令. Parameters ---------- path : str | Path YAML 配置文件路径 jobs : str | Sequence[str] | None 要执行的 job 名. None 表示执行全部. str 表示单个 job. 指定 job 时自动包含其传递依赖. variables : Mapping[str, Any] | None 运行时变量, 用于替换 ``${VAR}`` 占位符 strategy : str | None 执行策略, None 使用 YAML 中的策略或默认 ``"dependency"`` max_workers : int | None ``"thread"`` 策略的线程池大小 dry_run : bool 仅打印执行计划, 不执行 verbose : bool 打印任务生命周期 on_event : EventCallback | None 状态转换回调 state : StateBackend | None 断点续跑后端 concurrency_limits : Mapping[str, int] | None ``{concurrency_key: max_concurrent}`` 映射 Returns ------- RunReport 执行报告 Raises ------ YamlLoadError YAML 加载或 schema 校验失败 KeyError ``jobs`` 指定了不存在的 job 名 """ graph = load_yaml(path, variables=variables) if jobs is not None: job_names = [jobs] if isinstance(jobs, str) else list(jobs) all_names = _collect_with_deps(graph, job_names) graph = graph.subgraph_by_names(all_names) effective_strategy = strategy if strategy is not None else (graph.defaults.strategy or "dependency") return px.run( graph, strategy=cast(Strategy, effective_strategy), max_workers=max_workers, dry_run=dry_run, verbose=verbose, on_event=on_event, state=state, concurrency_limits=concurrency_limits, )
def _collect_with_deps(graph: px.Graph, names: Iterable[str]) -> set[str]: """收集 ``names`` 及其所有传递依赖, 用于子图选择.""" result: set[str] = set() queue: list[str] = list(names) while queue: name = queue.pop() if name in result: continue result.add(name) spec = graph.spec(name) queue.extend(spec.depends_on) return result def _parse_yaml(content: str) -> Any: """解析 YAML 文本, 失败抛出 YamlLoadError.""" try: import yaml except ImportError as e: # pragma: no cover raise YamlLoadError("缺少 PyYAML 依赖, 请执行 `pip install pyyaml`") from e try: return yaml.safe_load(content) except yaml.YAMLError as e: raise YamlLoadError(f"YAML 解析失败: {e}") from e def _merge_variables( yaml_vars: Any, caller_vars: Mapping[str, Any] | None, ) -> dict[str, Any]: """合并 YAML 中的 variables (默认值) 与调用方传入的 variables (后者优先).""" if isinstance(yaml_vars, Mapping): return {**yaml_vars, **(caller_vars or {})} return dict(caller_vars) if caller_vars else {} def _build_graph(data: Mapping[str, Any], variables: Mapping[str, Any] | None = None) -> px.Graph: """从解析后的 mapping 构建 Graph.""" jobs = data.get("jobs") if not isinstance(jobs, Mapping): raise YamlLoadError("YAML 必须包含 jobs 字段, 且 jobs 必须是 mapping") if not jobs: raise YamlLoadError("jobs 不能为空") merged_vars = _merge_variables(data.get("variables"), variables) defaults = _parse_defaults(data.get("defaults") or {}) # 顶层 strategy 覆盖 defaults.strategy top_strategy = data.get("strategy") if top_strategy is not None: if not isinstance(top_strategy, str): raise YamlLoadError("顶层 strategy 必须是字符串") defaults = px.GraphDefaults( retry=defaults.retry, timeout=defaults.timeout, strategy=top_strategy, tags=defaults.tags, env=defaults.env, cwd=defaults.cwd, priority=defaults.priority, continue_on_error=defaults.continue_on_error, concurrency_key=defaults.concurrency_key, verbose=defaults.verbose, ) # 第一阶段: 预计算每个 job 的展开名 (用于矩阵依赖展开) job_expansions: dict[str, list[str]] = {} for job_id, job_config in jobs.items(): if not isinstance(job_id, str) or not job_id: raise YamlLoadError(f"job id 必须是非空字符串, 收到: {job_id!r}") if not isinstance(job_config, Mapping): raise YamlLoadError(f"job {job_id!r} 必须是 mapping") matrix_combos = _expand_matrix(job_config.get("strategy")) if any(combo is not None for combo in matrix_combos): job_expansions[job_id] = [_matrix_name(job_id, c) for c in matrix_combos] else: job_expansions[job_id] = [job_id] # 第二阶段: 解析 job, 展开矩阵依赖 specs: list[TaskSpec[Any]] = [] seen_names: set[str] = set() for job_id, job_config in jobs.items(): for spec in _parse_job(job_id, job_config, job_expansions, variables=merged_vars): if spec.name in seen_names: raise YamlLoadError(f"重复任务名: {spec.name!r}") seen_names.add(spec.name) specs.append(spec) return px.Graph.from_specs(specs, defaults=defaults) def _parse_defaults(data: Mapping[str, Any]) -> px.GraphDefaults: """解析 defaults 字段为 GraphDefaults.""" if not data: return px.GraphDefaults() retry = _parse_retry(data["retry"]) if "retry" in data else None return px.GraphDefaults( retry=retry, timeout=_as_float(data.get("timeout")), strategy=_as_str(data.get("strategy")), tags=_as_str_tuple(data.get("tags")), env=_as_str_mapping(data.get("env")), cwd=_as_path(data.get("cwd")), priority=_as_int(data.get("priority")) or 0, continue_on_error=bool(data.get("continue-on-error") or data.get("continue_on_error") or False), concurrency_key=_as_str(data.get("concurrency-key") or data.get("concurrency_key")), verbose=bool(data.get("verbose") or False), ) def _parse_job( job_id: str, config: Mapping[str, Any], job_expansions: Mapping[str, list[str]], variables: Mapping[str, Any] | None = None, ) -> list[TaskSpec[Any]]: """解析单个 job, 返回 spec 列表 (矩阵展开可能产生多个). Parameters ---------- job_id : str job 标识符 config : Mapping[str, Any] job 配置 job_expansions : Mapping[str, list[str]] 每个 job_id 到其展开名列表的映射, 用于展开矩阵依赖 variables : Mapping[str, Any] | None 运行时变量, 用于替换 ``${VAR}`` 占位符 """ cmd_data, run_data, fn_data, args_data, kwargs_data = _parse_task_type_fields(job_id, config) matrix_combos = _expand_matrix(config.get("strategy")) if not matrix_combos: matrix_combos = [None] needs_tuple = _expand_needs(_as_str_tuple(config.get("needs")), job_expansions) if_data = config.get("if") timeout = _as_float(config.get("timeout")) retry = _parse_retry(config["retry"]) if "retry" in config else None cwd = _as_path(config.get("cwd")) env = _as_str_mapping(config.get("env")) verbose = bool(config.get("verbose")) continue_on_error = bool(config.get("continue-on-error") or config.get("continue_on_error") or False) skip_if_missing = bool(config.get("skip-if-missing") or config.get("skip_if_missing") or False) allow_upstream_skip = bool(config.get("allow-upstream-skip") or config.get("allow_upstream_skip") or False) priority = _as_int(config.get("priority")) or 0 concurrency_key = _as_str(config.get("concurrency-key") or config.get("concurrency_key")) tags = _as_str_tuple(config.get("tags")) runs_on = _as_str(config.get("runs-on") or config.get("runs_on")) if runs_on: tags = (*tags, runs_on) fn = _resolve_fn(job_id, fn_data) # 方向 B: 聚合 job (有 needs 无 cmd/run/fn) 注入 no-op fn, 仅作依赖聚合点 is_aggregate = fn is None and cmd_data is None and run_data is None and bool(needs_tuple) if is_aggregate: fn = _aggregate_noop specs: list[TaskSpec[Any]] = [] for combo in matrix_combos: name = _matrix_name(job_id, combo) cmd = _build_cmd(cmd_data, run_data, combo, variables) if fn_data is None and not is_aggregate else None cwd_resolved = Path(_replace_in_str(str(cwd), combo, variables)) if cwd is not None else None env_resolved = _replace_in_mapping(env, combo, variables) if env else None conditions = _parse_condition(if_data) if if_data is not None else () # fn 任务的 args/kwargs 应用占位符替换 args_resolved = _replace_in_value(args_data, combo, variables) if args_data is not None else () kwargs_resolved = _replace_in_value(kwargs_data, combo, variables) if kwargs_data is not None else {} spec = px.TaskSpec( name=name, fn=fn, cmd=cmd, args=tuple(args_resolved), kwargs=dict(kwargs_resolved), depends_on=needs_tuple, retry=retry if retry is not None else RetryPolicy(), timeout=timeout, tags=tags, conditions=conditions, cwd=cwd_resolved, env=env_resolved, verbose=verbose, skip_if_missing=skip_if_missing, allow_upstream_skip=allow_upstream_skip, priority=priority, concurrency_key=concurrency_key, continue_on_error=continue_on_error, ) specs.append(spec) return specs def _parse_task_type_fields( job_id: str, config: Mapping[str, Any], ) -> tuple[Any, Any, Any, Any, Any]: """解析并校验任务类型字段 (cmd/run/fn) 及参数 (args/kwargs). Returns ------- tuple (cmd_data, run_data, fn_data, args_data, kwargs_data) """ cmd_data = config.get("cmd") run_data = config.get("run") fn_data = config.get("fn") args_data = config.get("args") kwargs_data = config.get("kwargs") # 校验互斥: cmd/run/fn 三选一 provided = [x for x in (cmd_data, run_data, fn_data) if x is not None] if len(provided) > 1: raise YamlLoadError(f"job {job_id!r} 不能同时提供 cmd/run/fn") # 方向 B: 允许有 needs 无 cmd/run/fn 的聚合 job (仅作为依赖聚合点) if not provided and not config.get("needs"): raise YamlLoadError(f"job {job_id!r} 必须提供 cmd/run/fn 之一, 或提供 needs 作为聚合 job") # fn 必须是字符串 if fn_data is not None and not isinstance(fn_data, str): raise YamlLoadError(f"job {job_id!r} 的 fn 必须是字符串, 收到: {type(fn_data).__name__}") # args 必须是列表, kwargs 必须是 mapping if args_data is not None and not isinstance(args_data, list): raise YamlLoadError(f"job {job_id!r} 的 args 必须是列表, 收到: {type(args_data).__name__}") if kwargs_data is not None and not isinstance(kwargs_data, Mapping): raise YamlLoadError(f"job {job_id!r} 的 kwargs 必须是 mapping, 收到: {type(kwargs_data).__name__}") return cmd_data, run_data, fn_data, args_data, kwargs_data def _expand_needs( needs_raw: tuple[str, ...], job_expansions: Mapping[str, list[str]], ) -> tuple[str, ...]: """展开 needs 中的矩阵依赖.""" needs: list[str] = [] for dep in needs_raw: if dep in job_expansions: needs.extend(job_expansions[dep]) else: needs.append(dep) return tuple(needs) def _resolve_fn(job_id: str, fn_data: Any) -> Any: """从 registry 查找已注册的函数.""" if fn_data is None: return None try: return _get_fn(fn_data) except KeyError as e: raise YamlLoadError(f"job {job_id!r} 引用的函数 {fn_data!r} 未注册") from e def _expand_matrix(strategy_data: Any) -> list[Mapping[str, Any] | None]: """展开 strategy.matrix 为笛卡尔积列表. Returns ------- list[Mapping[str, Any] | None] 无矩阵时返回 [None], 有矩阵时返回笛卡尔积 """ if strategy_data is None: return [None] if not isinstance(strategy_data, Mapping): raise YamlLoadError("strategy 必须是 mapping") matrix = strategy_data.get("matrix") if matrix is None: return [None] if not isinstance(matrix, Mapping): raise YamlLoadError("strategy.matrix 必须是 mapping") if not matrix: return [None] keys = list(matrix.keys()) value_lists = [] for key in keys: vals = matrix[key] if isinstance(vals, (str, int, float, bool)): vals = [vals] elif not isinstance(vals, list): raise YamlLoadError(f"matrix.{key} 必须是列表或标量") if not vals: raise YamlLoadError(f"matrix.{key} 不能为空列表") value_lists.append([(key, v) for v in vals]) combos: list[Mapping[str, Any] | None] = [] for combo_tuples in itertools.product(*value_lists): combos.append(dict(combo_tuples)) return combos def _matrix_name(job_id: str, combo: Mapping[str, Any] | None) -> str: """生成矩阵展开后的任务名.""" if combo is None: return job_id parts = [job_id] for key, val in combo.items(): parts.append(f"{key}-{_sanitize_matrix_value(val)}") return "_".join(parts) def _sanitize_matrix_value(val: Any) -> str: """将矩阵值转为合法任务名片段.""" s = str(val) # 替换任务名中不允许的字符 return re.sub(r"[^a-zA-Z0-9._-]", "_", s) def _build_cmd( cmd_data: Any, run_data: Any, combo: Mapping[str, Any] | None, variables: Mapping[str, Any] | None = None, ) -> list[str] | str: """构建 cmd 字段, 应用占位符替换. 列表元素若完全匹配 ``${VAR}`` 且变量为列表时, 展开为多个元素; 否则按字符串替换处理. """ if cmd_data is not None: if isinstance(cmd_data, str): return _replace_in_str(cmd_data, combo, variables) if isinstance(cmd_data, list): result: list[str] = [] for item in cmd_data: if variables and isinstance(item, str): m = _VARIABLE_FULL_MATCH.match(item) if m and m.group(1) in variables: val = variables[m.group(1)] if isinstance(val, list): result.extend(str(v) for v in val) continue result.append(_replace_in_str(str(item), combo, variables)) return result raise YamlLoadError(f"cmd 必须是 list 或 str, 收到: {type(cmd_data).__name__}") # run_data 不为 None (已在外层校验) return _replace_in_str(str(run_data), combo, variables) def _replace_in_str( text: str, combo: Mapping[str, Any] | None, variables: Mapping[str, Any] | None = None, ) -> str: """替换字符串中的 ``${VAR}`` 变量占位符和 ``${{ matrix.key }}`` 矩阵占位符.""" # 先替换变量占位符 ${VAR} if variables: def _sub_var(match: re.Match[str]) -> str: key = match.group(1) if key not in variables: raise YamlLoadError(f"变量 {key!r} 未定义") return str(variables[key]) text = _VARIABLE_PLACEHOLDER.sub(_sub_var, text) # 再替换矩阵占位符 ${{ matrix.key }} if combo is not None: def _sub_matrix(match: re.Match[str]) -> str: key = match.group(1) if key not in combo: raise YamlLoadError(f"矩阵占位符 matrix.{key} 未定义") return str(combo[key]) text = _MATRIX_PLACEHOLDER.sub(_sub_matrix, text) return text def _replace_in_mapping( env: Mapping[str, str], combo: Mapping[str, Any] | None, variables: Mapping[str, Any] | None = None, ) -> dict[str, str]: """替换 env 映射中的占位符.""" return {k: _replace_in_str(v, combo, variables) for k, v in env.items()} def _replace_in_value( value: Any, combo: Mapping[str, Any] | None, variables: Mapping[str, Any] | None = None, ) -> Any: """递归替换值 (list/mapping/scalar) 中的占位符. 当字符串完全等于 ``${VAR}`` 时, 返回变量的实际值 (非字符串化), 适用于 ``args: ["${FILES}"]`` 这种需要传递列表/对象而非字符串的场景. """ if isinstance(value, str) and variables: m = _VARIABLE_FULL_MATCH.match(value) if m and m.group(1) in variables: return variables[m.group(1)] if isinstance(value, str): return _replace_in_str(value, combo, variables) if isinstance(value, list): return [_replace_in_value(v, combo, variables) for v in value] if isinstance(value, Mapping): return {k: _replace_in_value(v, combo, variables) for k, v in value.items()} return value def _parse_condition(expr: Any) -> tuple[Condition, ...]: """解析 if 条件表达式为 Condition 元组.""" if not isinstance(expr, str): raise YamlLoadError(f"if 条件必须是字符串, 收到: {type(expr).__name__}") expr = expr.strip() if not expr: raise YamlLoadError("if 条件不能为空") lowered = expr.lower() if lowered in ("success()", "success"): # 默认行为: 依赖成功才执行. 不设 condition. return () if lowered in ("always()", "always"): # always() 在 PyFlowX 中无直接对应, 返回空 condition 元组, # 由调用方通过 allow_upstream_skip=True 实现. return () if lowered in ("failure()", "failure"): raise YamlLoadError("if: failure() 在 PyFlowX 中不支持 (失败会中止或跳过下游)") # env.VAR_EXISTS m = _ENV_VAR_EXISTS_RE.match(expr) if m: return (BuiltinConditions.ENV_VAR_EXISTS(m.group(1)),) # env.VAR == 'value' m = _ENV_VAR_EQUALS_RE.match(expr) if m: return (BuiltinConditions.ENV_VAR_EQUALS(m.group(1), m.group(2)),) # env.VAR != 'value' m = _ENV_VAR_NOT_EQUALS_RE.match(expr) if m: return (BuiltinConditions.NOT(BuiltinConditions.ENV_VAR_EQUALS(m.group(1), m.group(2))),) raise YamlLoadError(f"不支持的 if 表达式: {expr!r} (支持 success()/always()/env.VAR/env.VAR=='x')") def _parse_retry(data: Any) -> RetryPolicy: """解析 retry 配置为 RetryPolicy.""" if data is None: return RetryPolicy() if isinstance(data, Mapping): return RetryPolicy( max_attempts=int(data.get("max_attempts", data.get("max-attempts", 1))), delay=float(data.get("delay", 0.0)), backoff=float(data.get("backoff", 1.0)), jitter=float(data.get("jitter", 0.0)), retry_on=(Exception,), # 默认重试所有异常 ) raise YamlLoadError(f"retry 必须是 mapping, 收到: {type(data).__name__}") def _as_float(value: Any) -> float | None: """安全转换为 float.""" if value is None: return None if isinstance(value, (int, float)): return float(value) raise YamlLoadError(f"期望数字, 收到: {value!r}") def _as_int(value: Any) -> int | None: """安全转换为 int.""" if value is None: return None if isinstance(value, int) and not isinstance(value, bool): return value if isinstance(value, float): return int(value) raise YamlLoadError(f"期望整数, 收到: {value!r}") def _as_str(value: Any) -> str | None: """安全转换为 str.""" if value is None: return None if isinstance(value, str): return value raise YamlLoadError(f"期望字符串, 收到: {value!r}") def _as_str_tuple(value: Any) -> tuple[str, ...]: """转换为字符串元组.""" if value is None: return () if isinstance(value, str): return (value,) if isinstance(value, list): return tuple(str(v) for v in value) raise YamlLoadError(f"期望字符串或列表, 收到: {value!r}") def _as_str_mapping(value: Any) -> dict[str, str] | None: """转换为 {str: str} 映射.""" if value is None: return None if not isinstance(value, Mapping): raise YamlLoadError(f"期望 mapping, 收到: {value!r}") return {str(k): str(v) for k, v in value.items()} def _as_path(value: Any) -> Path | None: """转换为 Path.""" if value is None: return None return Path(str(value)) # ============================================================================ # CLI 参数解析 # ============================================================================ # 支持的 CLI 参数类型 _CLI_TYPE_MAP: dict[str, type] = { "str": str, "int": int, "float": float, "path": Path, } def _parse_cli_type(type_str: str | None) -> type: """解析 CLI 参数类型字符串.""" if type_str is None: return str if type_str in _CLI_TYPE_MAP: return _CLI_TYPE_MAP[type_str] raise YamlLoadError(f"不支持的 CLI 参数类型: {type_str!r}")
[文档] def build_cli_parser(data: Mapping[str, Any]) -> Any: """从 YAML 配置的 ``cli:`` 段构建 argparse 解析器. Parameters ---------- data : Mapping[str, Any] 解析后的 YAML 配置 (包含 ``cli`` 和 ``jobs``) Returns ------- argparse.ArgumentParser 构建好的参数解析器 Raises ------ YamlLoadError cli 段格式错误 """ import argparse cli_raw = data.get("cli") if cli_raw is None: cli_config: Mapping[str, Any] = {} elif not isinstance(cli_raw, Mapping): raise YamlLoadError(f"cli 必须是 mapping, 收到: {type(cli_raw).__name__}") else: cli_config = cli_raw description = str(cli_config.get("description", "")) usage = str(cli_config.get("usage", "")) if cli_config.get("usage") else None parser = argparse.ArgumentParser(description=description, usage=usage) subcommands = cli_config.get("subcommands") if subcommands is not None: if not isinstance(subcommands, Mapping): raise YamlLoadError(f"cli.subcommands 必须是 mapping, 收到: {type(subcommands).__name__}") subparsers = parser.add_subparsers(dest="command", help="可用命令") for sub_name, sub_config in subcommands.items(): if not isinstance(sub_config, Mapping): raise YamlLoadError(f"cli.subcommands.{sub_name} 必须是 mapping") sub = subparsers.add_parser(sub_name, help=str(sub_config.get("help", ""))) _add_args_to_parser(sub, sub_config) else: _add_args_to_parser(parser, cli_config) return parser
def _add_args_to_parser(parser: Any, config: Mapping[str, Any]) -> None: # noqa: PLR0912 """向 parser 添加位置参数和选项参数.""" positional = config.get("positional", []) if positional: if not isinstance(positional, list): raise YamlLoadError("positional 必须是列表") for arg_cfg in positional: if not isinstance(arg_cfg, Mapping): raise YamlLoadError("positional 元素必须是 mapping") name = str(arg_cfg["name"]) kwargs: dict[str, Any] = {} if "help" in arg_cfg: kwargs["help"] = str(arg_cfg["help"]) if "nargs" in arg_cfg: kwargs["nargs"] = arg_cfg["nargs"] if "type" in arg_cfg: kwargs["type"] = _parse_cli_type(str(arg_cfg["type"])) if "default" in arg_cfg: kwargs["default"] = arg_cfg["default"] parser.add_argument(name.lower().replace("-", "_"), **kwargs) options = config.get("options", []) if options: if not isinstance(options, list): raise YamlLoadError("options 必须是列表") for opt_cfg in options: if not isinstance(opt_cfg, Mapping): raise YamlLoadError("options 元素必须是 mapping") name = str(opt_cfg["name"]) flag = str(opt_cfg.get("flag", f"--{name.lower().replace('_', '-')}")) kwargs = {} if "help" in opt_cfg: kwargs["help"] = str(opt_cfg["help"]) if "type" in opt_cfg: kwargs["type"] = _parse_cli_type(str(opt_cfg["type"])) if "default" in opt_cfg: kwargs["default"] = opt_cfg["default"] if "required" in opt_cfg: kwargs["required"] = bool(opt_cfg["required"]) if "action" in opt_cfg: kwargs["action"] = str(opt_cfg["action"]) parser.add_argument(flag, dest=name.lower().replace("-", "_"), **kwargs) class YamlCliRunner: """从 YAML 配置构建并执行 CLI. 读取 YAML 的 ``cli:`` 段构建 argparse 解析器, 解析命令行参数, 然后调用 :func:`run_yaml` 执行对应的 job. 全局选项 ``--dry-run`` / ``--quiet`` / ``--strategy`` / ``--list`` 自动添加到所有工具. """ def __init__(self, path: str | Path, argv: Sequence[str] | None = None) -> None: self._path = Path(path) self._argv = list(argv) if argv is not None else sys.argv[1:] self._data: Mapping[str, Any] = {} self._cli_config: Mapping[str, Any] = {} def run(self) -> int: # noqa: PLR0911 """执行 CLI, 返回退出码 (0 成功, 1 失败).""" from pyflowx.errors import TaskFailedError if not self._path.exists(): print(f"错误: YAML 文件不存在: {self._path}", file=sys.stderr) return 1 if not self._load_config(): return 1 try: parser = build_cli_parser(self._data) except YamlLoadError as e: print(f"错误: CLI 配置错误: {e}", file=sys.stderr) return 1 self._add_global_options(parser) args = parser.parse_args(self._argv) has_subcommands = bool(self._cli_config.get("subcommands")) cmd = getattr(args, "command", None) if has_subcommands else None job_name: str | None = cmd # --list 优先于 cmd 缺失判断 if getattr(args, "list_jobs", False): return self._handle_list() if has_subcommands and cmd is None: parser.print_help() return 0 variables = self._extract_variables(args) job_display = job_name if job_name else "全部任务" print(f"[{self._path.stem}] 执行: {job_display}", flush=True) try: run_yaml( self._path, jobs=job_name, variables=variables if variables else None, strategy=getattr(args, "strategy", None), dry_run=getattr(args, "dry_run", False), verbose=getattr(args, "verbose", False), ) return 0 except TaskFailedError as e: print(f"错误: {e}", file=sys.stderr) return 1 except YamlLoadError as e: print(f"错误: {e}", file=sys.stderr) return 1 except KeyboardInterrupt: print("\n已中断", file=sys.stderr) return 130 def _load_config(self) -> bool: """加载并校验 YAML 配置, 返回是否成功.""" try: content = self._path.read_text(encoding="utf-8") data = _parse_yaml(content) except YamlLoadError as e: print(f"错误: YAML 加载失败: {e}", file=sys.stderr) return False if not isinstance(data, Mapping): print("错误: YAML 根节点必须是 mapping", file=sys.stderr) return False cli_raw = data.get("cli") or {} if not isinstance(cli_raw, Mapping): print("错误: cli 段格式错误", file=sys.stderr) return False self._data = data self._cli_config = cli_raw return True def _add_global_options(self, parser: Any) -> None: """向解析器添加全局选项. verbose 默认开启 (显示执行过程), 用 --quiet 关闭. """ parser.add_argument("--dry-run", action="store_true", help="仅打印执行计划, 不执行") parser.add_argument("--quiet", "-q", action="store_false", dest="verbose", help="减少输出, 不显示执行过程") parser.add_argument("--strategy", type=str, default=None, help="执行策略 (sequential/thread/dependency)") parser.add_argument("--list", action="store_true", dest="list_jobs", help="列出所有任务名后退出") # 给子命令也添加全局选项 for action in parser._actions: if action.dest == "command" and hasattr(action, "choices"): for sub in action.choices.values(): sub.add_argument("--dry-run", action="store_true", help="仅打印执行计划, 不执行") sub.add_argument("--quiet", "-q", action="store_false", dest="verbose", help="减少输出") sub.add_argument("--strategy", type=str, default=None, help="执行策略") sub.add_argument("--list", action="store_true", dest="list_jobs", help="列出所有任务名后退出") def _extract_variables(self, args: Any) -> dict[str, Any]: """从解析后的 namespace 提取变量字典 (大写 key).""" variables: dict[str, Any] = {} arg_names: set[str] = set() subcommands = self._cli_config.get("subcommands") if subcommands is not None and isinstance(subcommands, Mapping): cmd = getattr(args, "command", None) if cmd and cmd in subcommands: sub_cfg = subcommands[cmd] if isinstance(sub_cfg, Mapping): self._collect_arg_names(arg_names, sub_cfg) else: self._collect_arg_names(arg_names, self._cli_config) for name in arg_names: attr_name = name.lower().replace("-", "_") if hasattr(args, attr_name): variables[name] = getattr(args, attr_name) return variables @staticmethod def _collect_arg_names(names: set[str], config: Mapping[str, Any]) -> None: """收集配置中的参数名.""" for arg_cfg in config.get("positional", []) or []: if isinstance(arg_cfg, Mapping) and "name" in arg_cfg: names.add(str(arg_cfg["name"])) for opt_cfg in config.get("options", []) or []: if isinstance(opt_cfg, Mapping) and "name" in opt_cfg: names.add(str(opt_cfg["name"])) def _handle_list(self) -> int: """处理 --list 选项, 打印任务列表.""" graph = load_yaml(self._path) print("任务列表:") for name in graph.names: spec = graph.spec(name) deps = ", ".join(spec.depends_on) if spec.depends_on else "(无依赖)" print(f" - {name} (依赖: {deps})") return 0
[文档] def run_cli(path: str | Path, argv: Sequence[str] | None = None) -> int: """从 YAML 配置构建 CLI 并执行. :class:`YamlCliRunner` 的便捷包装. Parameters ---------- path : str | Path YAML 配置文件路径 argv : Sequence[str] | None 命令行参数, None 表示使用 sys.argv[1:] Returns ------- int 退出码 (0 成功, 1 失败) """ return YamlCliRunner(path, argv).run()