2026-09-14 13:36:18 +08:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""run_extension.py —— 扩展分析容器通用入口(宿主挂载 /opt/bioflow-entry,镜像零改动)。
|
|
|
|
|
|
|
|
|
|
执行形态(契约 §3):
|
|
|
|
|
python -B /opt/bioflow-entry/run_extension.py --ext-dir /job/ext \
|
|
|
|
|
--input-dir /job/input --output-dir /job/output [--param name=value ...]
|
|
|
|
|
|
|
|
|
|
职责:
|
|
|
|
|
1. 读 <ext-dir>/extension.json(command_template 唯一事实源,契约 §2 schema)。
|
|
|
|
|
2. render_command_template:移植 175 executor 模板渲染纯函数(白名单占位符
|
|
|
|
|
12 个 + {param_*};{output_dir} 必含)。
|
|
|
|
|
3. subprocess 执行(无 shell:shlex 切分;stdout/stderr 透传;超时进程组
|
|
|
|
|
SIGTERM → 3s → SIGKILL,与 176 服务侧 terminate→kill 约定同源)。
|
|
|
|
|
4. 输出契约校验:输出目录非空,否则退出码 3。
|
|
|
|
|
|
|
|
|
|
退出码约定(容器内零第三方依赖,仅 python 标准库):
|
|
|
|
|
0 成功
|
|
|
|
|
1.. 命令自身退出码透传(1/2/7/137…)
|
|
|
|
|
2 extension.json 缺失/非法、模板渲染失败、白名单外占位符、缺 {output_dir}
|
|
|
|
|
3 输出契约校验失败(输出目录为空)
|
|
|
|
|
124 执行超时(进程组已终止)
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import shlex
|
|
|
|
|
import signal
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
EXIT_OK = 0
|
|
|
|
|
EXIT_SETUP = 2
|
|
|
|
|
EXIT_OUTPUT_CONTRACT = 3
|
|
|
|
|
EXIT_TIMEOUT = 124
|
|
|
|
|
|
|
|
|
|
DEFAULT_TIMEOUT_SEC = 36000.0 # 与 176 单阶段 10h 超时对齐
|
|
|
|
|
|
|
|
|
|
# 占位符白名单(spec_v2 PLACEHOLDERS 移植;扩展 {param_<name>})
|
|
|
|
|
PLACEHOLDERS = {
|
|
|
|
|
"code_dir",
|
|
|
|
|
"input_dir",
|
|
|
|
|
"input_file",
|
|
|
|
|
"input_hmm",
|
|
|
|
|
"input_fasta",
|
|
|
|
|
"input_query_fasta",
|
|
|
|
|
"input_reference_fasta",
|
|
|
|
|
"input_pep_fasta",
|
|
|
|
|
"input_cds_fasta",
|
|
|
|
|
"input_id_file",
|
|
|
|
|
"output_dir",
|
|
|
|
|
"work_dir",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_ID_SUFFIXES = {".id", ".ids", ".geneid", ".geneids", ".txt", ".tsv", ".csv"}
|
|
|
|
|
_FASTA_SUFFIXES = {".fa", ".fasta", ".faa", ".fsa", ".pep"}
|
|
|
|
|
_GFF_SUFFIXES = {".gff", ".gff3"}
|
|
|
|
|
|
|
|
|
|
_ACTIVE_PROC: subprocess.Popen | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ExtensionRenderError(RuntimeError):
|
|
|
|
|
"""模板渲染失败(占位符缺失/参数缺值)。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 模板渲染纯函数(executor.py 移植裁剪;不 import gfa_core,零第三方依赖)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _safe_parameter_name(value: Any) -> str:
|
|
|
|
|
text = re.sub(r"[^A-Za-z0-9_]+", "_", str(value or "").strip())
|
|
|
|
|
text = re.sub(r"_+", "_", text).strip("_")
|
|
|
|
|
if not text:
|
|
|
|
|
return ""
|
|
|
|
|
if text[0].isdigit():
|
|
|
|
|
text = "p_" + text
|
|
|
|
|
if text.startswith("param_"):
|
|
|
|
|
text = text[6:]
|
|
|
|
|
return text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _shell_parameter_value(value: Any) -> str:
|
|
|
|
|
if isinstance(value, bool):
|
|
|
|
|
return "true" if value else "false"
|
|
|
|
|
if value is None:
|
|
|
|
|
return ""
|
|
|
|
|
text = str(value)
|
|
|
|
|
if re.fullmatch(r"[A-Za-z0-9_./:+,@%-]+", text):
|
|
|
|
|
return text
|
|
|
|
|
return shlex.quote(text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _first_input_by_suffix(paths: list[Path], suffixes: set[str]) -> str:
|
|
|
|
|
for path in paths:
|
|
|
|
|
name = path.name.lower()
|
|
|
|
|
if path.suffix.lower() in suffixes or any(name.endswith(s) for s in suffixes):
|
|
|
|
|
return path.as_posix()
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _input_files_by_suffix(paths: list[Path], suffixes: set[str]) -> list[Path]:
|
|
|
|
|
matched = []
|
|
|
|
|
for path in paths:
|
|
|
|
|
name = path.name.lower()
|
|
|
|
|
if path.suffix.lower() in suffixes or any(name.endswith(s) for s in suffixes):
|
|
|
|
|
matched.append(path)
|
|
|
|
|
return matched
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _first_input_by_name(paths: list[Path], tokens: set[str]) -> str:
|
|
|
|
|
for path in paths:
|
|
|
|
|
name = path.name.lower()
|
|
|
|
|
if any(token in name for token in tokens):
|
|
|
|
|
return path.as_posix()
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _largest_file(paths: list[Path]) -> str:
|
|
|
|
|
if not paths:
|
|
|
|
|
return ""
|
|
|
|
|
return max(paths, key=lambda p: p.stat().st_size if p.exists() else -1).as_posix()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _smallest_file(paths: list[Path]) -> str:
|
|
|
|
|
if not paths:
|
|
|
|
|
return ""
|
|
|
|
|
return min(paths, key=lambda p: p.stat().st_size if p.exists() else 10**18).as_posix()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _role_of(extension: dict[str, Any], placeholder: str) -> str:
|
|
|
|
|
"""input_roles 双形态兼容:契约 §2 平铺 {"input_file": "fastq"},175 旧版
|
|
|
|
|
{"input_file": {"role": "generic_input_file"}}。仅用于语义占位符消歧。"""
|
|
|
|
|
roles = extension.get("input_roles")
|
|
|
|
|
if not isinstance(roles, dict):
|
|
|
|
|
return ""
|
|
|
|
|
spec = roles.get(placeholder)
|
|
|
|
|
if isinstance(spec, dict):
|
|
|
|
|
return str(spec.get("role") or "").lower()
|
|
|
|
|
return str(spec or "").lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _used_placeholders(template: str) -> set[str]:
|
|
|
|
|
return set(re.findall(r"\{([A-Za-z_][A-Za-z0-9_]*)\}", template or ""))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_template(template: str) -> str | None:
|
|
|
|
|
"""模板白名单校验(与 spec_v2 normalize 同纪律);None = 通过。"""
|
|
|
|
|
if not template.strip():
|
|
|
|
|
return "extension.json 缺少 command_template"
|
|
|
|
|
if "{output_dir}" not in template:
|
|
|
|
|
return "command_template 必须包含 {output_dir}(输出契约硬约束)"
|
|
|
|
|
bad = sorted(
|
|
|
|
|
name for name in _used_placeholders(template)
|
|
|
|
|
if name not in PLACEHOLDERS and not name.startswith("param_")
|
|
|
|
|
)
|
|
|
|
|
if bad:
|
|
|
|
|
return "command_template 含白名单外占位符: " + ", ".join(bad)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_render_context(
|
|
|
|
|
extension: dict[str, Any],
|
|
|
|
|
*,
|
|
|
|
|
run_dir: Path,
|
|
|
|
|
input_dir: Path,
|
|
|
|
|
input_files: list[Path],
|
|
|
|
|
parameters: dict[str, Any] | None = None,
|
|
|
|
|
output_dir: Path | None = None,
|
|
|
|
|
code_dir: Path | None = None,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""渲染上下文:12 个白名单占位符(语义占位符按输入文件后缀/角色推导)+ param_*。"""
|
|
|
|
|
output_dir = output_dir or (run_dir / "outputs")
|
|
|
|
|
code_dir = code_dir or (run_dir / "code")
|
|
|
|
|
input_file_role = _role_of(extension, "input_file")
|
|
|
|
|
id_file = _first_input_by_suffix(input_files, _ID_SUFFIXES)
|
|
|
|
|
annotation_file = (
|
|
|
|
|
_first_input_by_suffix(input_files, _GFF_SUFFIXES)
|
|
|
|
|
if ("annotation_gff" in input_file_role or "gff" in input_file_role)
|
|
|
|
|
else ""
|
|
|
|
|
)
|
|
|
|
|
cis_element_file = (
|
|
|
|
|
_first_input_by_name(input_files, {"plantcare", "cis_element", "cis-elements", "cis.elements"})
|
|
|
|
|
if input_file_role == "generic_input_file"
|
|
|
|
|
else ""
|
|
|
|
|
)
|
|
|
|
|
input_file = (
|
|
|
|
|
annotation_file or cis_element_file or id_file
|
|
|
|
|
or (input_files[0].as_posix() if input_files else input_dir.as_posix())
|
|
|
|
|
)
|
|
|
|
|
input_hmm = _first_input_by_suffix(input_files, {".hmm"}) or input_file
|
|
|
|
|
fasta_paths = _input_files_by_suffix(input_files, _FASTA_SUFFIXES)
|
|
|
|
|
input_fasta = fasta_paths[0].as_posix() if fasta_paths else input_file
|
|
|
|
|
query_fasta = _largest_file(fasta_paths) or input_fasta
|
|
|
|
|
reference_fasta = (
|
|
|
|
|
_smallest_file([p for p in fasta_paths if p.as_posix() != query_fasta])
|
|
|
|
|
if len(fasta_paths) > 1 else input_fasta
|
|
|
|
|
)
|
|
|
|
|
pep_fasta = _first_input_by_name(fasta_paths, {"pep", "protein", "faa"}) or query_fasta
|
|
|
|
|
cds_fasta = _first_input_by_name(fasta_paths, {"cds"}) or input_fasta
|
|
|
|
|
render_context: dict[str, Any] = {
|
|
|
|
|
"code_dir": code_dir.as_posix(),
|
|
|
|
|
"input_dir": input_dir.as_posix(),
|
|
|
|
|
"input_file": input_file,
|
|
|
|
|
"input_hmm": input_hmm,
|
|
|
|
|
"input_fasta": input_fasta,
|
|
|
|
|
"input_query_fasta": query_fasta,
|
|
|
|
|
"input_reference_fasta": reference_fasta,
|
|
|
|
|
"input_pep_fasta": pep_fasta,
|
|
|
|
|
"input_cds_fasta": cds_fasta,
|
|
|
|
|
"input_id_file": id_file or input_file,
|
|
|
|
|
"output_dir": output_dir.as_posix(),
|
|
|
|
|
"work_dir": run_dir.as_posix(),
|
|
|
|
|
}
|
|
|
|
|
for name, value in (parameters or {}).items():
|
|
|
|
|
render_context["param_" + _safe_parameter_name(name)] = _shell_parameter_value(value)
|
|
|
|
|
return render_context
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_command_template(
|
|
|
|
|
extension: dict[str, Any],
|
|
|
|
|
*,
|
|
|
|
|
template: str,
|
|
|
|
|
run_dir: Path,
|
|
|
|
|
input_dir: Path,
|
|
|
|
|
input_files: list[Path],
|
|
|
|
|
parameters: dict[str, Any] | None = None,
|
|
|
|
|
output_dir: Path | None = None,
|
|
|
|
|
code_dir: Path | None = None,
|
|
|
|
|
) -> str:
|
|
|
|
|
"""模板渲染纯函数(executor.py 同签名移植)。占位符缺值 → ExtensionRenderError。"""
|
|
|
|
|
context = build_render_context(
|
|
|
|
|
extension,
|
|
|
|
|
run_dir=run_dir,
|
|
|
|
|
input_dir=input_dir,
|
|
|
|
|
input_files=input_files,
|
|
|
|
|
parameters=parameters,
|
|
|
|
|
output_dir=output_dir,
|
|
|
|
|
code_dir=code_dir,
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
return template.format(**context)
|
|
|
|
|
except KeyError as exc:
|
|
|
|
|
missing = str(exc).strip("'\"")
|
|
|
|
|
raise ExtensionRenderError(f"Missing command template value: {missing}") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 执行与输出契约
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _output_contract_ok(output_dir: Path) -> bool:
|
|
|
|
|
"""输出契约:输出目录存在且非空(至少一个条目)。"""
|
|
|
|
|
return output_dir.is_dir() and any(output_dir.iterdir())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _find_code_dir(ext_dir: Path) -> Path | None:
|
|
|
|
|
"""代码目录:converted_code 优先(契约 §1 布局),raw_code 兜底。"""
|
|
|
|
|
for name in ("converted_code", "raw_code"):
|
|
|
|
|
candidate = ext_dir / name
|
|
|
|
|
if candidate.is_dir():
|
|
|
|
|
return candidate
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-09-15 11:59:42 +08:00
|
|
|
def _copy_code_tree(src: Path, dst: Path) -> set[str]:
|
|
|
|
|
"""把代码树复制进可写目录;返回原始代码相对路径集合(收割时排除)。"""
|
2026-09-15 11:58:17 +08:00
|
|
|
import shutil
|
2026-09-15 11:59:42 +08:00
|
|
|
rels: set[str] = set()
|
2026-09-15 11:58:17 +08:00
|
|
|
if not src.is_dir():
|
2026-09-15 11:59:42 +08:00
|
|
|
return rels
|
|
|
|
|
for item in src.rglob("*"):
|
|
|
|
|
rel = item.relative_to(src).as_posix()
|
|
|
|
|
dest = dst / rel
|
2026-09-15 11:58:17 +08:00
|
|
|
if item.is_dir():
|
2026-09-15 11:59:42 +08:00
|
|
|
dest.mkdir(parents=True, exist_ok=True)
|
2026-09-15 11:58:17 +08:00
|
|
|
else:
|
2026-09-15 11:59:42 +08:00
|
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
2026-09-15 11:58:17 +08:00
|
|
|
shutil.copy2(item, dest)
|
2026-09-15 11:59:42 +08:00
|
|
|
rels.add(rel)
|
|
|
|
|
return rels
|
2026-09-15 11:58:17 +08:00
|
|
|
|
|
|
|
|
|
2026-09-15 11:59:42 +08:00
|
|
|
def _harvest_outputs(src: Path, dst: Path, exclude: set[str]) -> None:
|
|
|
|
|
"""把 src 内产物(排除原始代码路径)合并进 dst,保留相对路径。"""
|
2026-09-15 11:58:17 +08:00
|
|
|
import shutil
|
2026-09-15 11:59:42 +08:00
|
|
|
for item in src.rglob("*"):
|
|
|
|
|
rel = item.relative_to(src).as_posix()
|
|
|
|
|
if rel in exclude:
|
|
|
|
|
continue
|
|
|
|
|
# 排除路径是原始代码文件所在目录时也跳过其祖先目录本身
|
|
|
|
|
if any(rel == e or rel.startswith(e + "/") for e in exclude):
|
|
|
|
|
continue
|
|
|
|
|
dest = dst / rel
|
2026-09-15 11:58:17 +08:00
|
|
|
if item.is_dir():
|
2026-09-15 11:59:42 +08:00
|
|
|
dest.mkdir(parents=True, exist_ok=True)
|
2026-09-15 11:58:17 +08:00
|
|
|
else:
|
2026-09-15 11:59:42 +08:00
|
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
2026-09-15 11:58:17 +08:00
|
|
|
shutil.copy2(item, dest)
|
|
|
|
|
|
|
|
|
|
|
2026-09-14 13:36:18 +08:00
|
|
|
def _kill_group(pid: int, sig: int) -> None:
|
|
|
|
|
try:
|
|
|
|
|
os.killpg(pid, sig)
|
|
|
|
|
except (ProcessLookupError, PermissionError):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sigterm_handler(signum: int, frame: Any) -> None:
|
|
|
|
|
"""docker stop 的 SIGTERM:转发到子进程组后以 124 退出(日志可辨)。"""
|
|
|
|
|
proc = _ACTIVE_PROC
|
|
|
|
|
if proc is not None and proc.poll() is None:
|
|
|
|
|
_kill_group(proc.pid, signal.SIGTERM)
|
|
|
|
|
sys.exit(EXIT_TIMEOUT)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _run_command(command: list[str], cwd: Path, env: dict[str, str], timeout_sec: float) -> int:
|
|
|
|
|
"""执行命令(stdout/stderr 直接透传容器流;超时进程组 SIGTERM→3s→SIGKILL)。
|
|
|
|
|
|
|
|
|
|
可执行文件缺失(镜像缺工具)→ 可读退出码 2(不抛 traceback)。"""
|
|
|
|
|
global _ACTIVE_PROC
|
|
|
|
|
try:
|
|
|
|
|
proc = subprocess.Popen(command, cwd=str(cwd), env=env, start_new_session=True)
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
print(f"[run_extension] 可执行文件缺失(镜像 PATH 中不存在): {command[0]}",
|
|
|
|
|
file=sys.stderr)
|
|
|
|
|
return EXIT_SETUP
|
|
|
|
|
_ACTIVE_PROC = proc
|
|
|
|
|
try:
|
|
|
|
|
return proc.wait(timeout=timeout_sec)
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
_kill_group(proc.pid, signal.SIGTERM)
|
|
|
|
|
try:
|
|
|
|
|
proc.wait(timeout=3)
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
_kill_group(proc.pid, signal.SIGKILL)
|
|
|
|
|
proc.wait()
|
|
|
|
|
return EXIT_TIMEOUT
|
|
|
|
|
finally:
|
|
|
|
|
_ACTIVE_PROC = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_params(items: list[str]) -> dict[str, Any]:
|
|
|
|
|
parameters: dict[str, Any] = {}
|
|
|
|
|
for item in items:
|
|
|
|
|
name, sep, value = item.partition("=")
|
|
|
|
|
if not sep or not name.strip():
|
|
|
|
|
raise ExtensionRenderError(f"invalid --param (需要 name=value): {item}")
|
|
|
|
|
parameters[name.strip()] = value
|
|
|
|
|
return parameters
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
prog="run_extension",
|
|
|
|
|
description="扩展分析容器通用入口(契约 EXTENSION_CONTRACT §3)",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument("--ext-dir", default="/job/ext", help="扩展目录(挂载 :ro)")
|
|
|
|
|
parser.add_argument("--input-dir", default="/job/input", help="输入目录")
|
|
|
|
|
parser.add_argument("--output-dir", default="/job/output", help="输出目录(契约校验对象)")
|
|
|
|
|
parser.add_argument("--work-dir", default="", help="工作目录(默认=输出目录)")
|
|
|
|
|
parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_SEC, help="执行超时(秒)")
|
|
|
|
|
parser.add_argument("--param", action="append", default=[], help="参数 name=value(可重复)")
|
|
|
|
|
args = parser.parse_args(argv)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
signal.signal(signal.SIGTERM, _sigterm_handler)
|
|
|
|
|
except ValueError: # 非主线程(测试)不支持 handler
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
ext_dir = Path(args.ext_dir)
|
|
|
|
|
ext_json_path = ext_dir / "extension.json"
|
|
|
|
|
if not ext_json_path.is_file():
|
|
|
|
|
print(f"[run_extension] extension.json 缺失: {ext_json_path}", file=sys.stderr)
|
|
|
|
|
return EXIT_SETUP
|
|
|
|
|
try:
|
|
|
|
|
extension = json.loads(ext_json_path.read_text(encoding="utf-8"))
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
print(f"[run_extension] extension.json 解析失败: {exc}", file=sys.stderr)
|
|
|
|
|
return EXIT_SETUP
|
|
|
|
|
if not isinstance(extension, dict):
|
|
|
|
|
print("[run_extension] extension.json 必须是 JSON 对象", file=sys.stderr)
|
|
|
|
|
return EXIT_SETUP
|
|
|
|
|
|
|
|
|
|
template = str(extension.get("command_template") or "")
|
|
|
|
|
error = _validate_template(template)
|
|
|
|
|
if error:
|
|
|
|
|
print(f"[run_extension] {error}", file=sys.stderr)
|
|
|
|
|
return EXIT_SETUP
|
|
|
|
|
|
|
|
|
|
code_dir = _find_code_dir(ext_dir)
|
|
|
|
|
if code_dir is None:
|
|
|
|
|
print("[run_extension] 代码目录缺失(converted_code/raw_code 均不存在)", file=sys.stderr)
|
|
|
|
|
return EXIT_SETUP
|
|
|
|
|
|
|
|
|
|
input_dir = Path(args.input_dir)
|
|
|
|
|
output_dir = Path(args.output_dir)
|
|
|
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
2026-09-15 11:58:17 +08:00
|
|
|
# 2026-09-15 修复:代码挂载只读,但 18 用例语义是「cd 代码目录后脚本写
|
|
|
|
|
# outputs/ 相对目录」。等价做法 = 把代码复制进可写 run_dir 执行,
|
|
|
|
|
# 结束后把 run_dir 产物收割进 output_dir。显式 --work-dir 可覆盖。
|
2026-09-15 11:59:42 +08:00
|
|
|
work_dir = Path(args.work_dir) if args.work_dir else (output_dir / "_work")
|
2026-09-14 13:36:18 +08:00
|
|
|
work_dir.mkdir(parents=True, exist_ok=True)
|
2026-09-15 11:59:42 +08:00
|
|
|
code_rel_paths = _copy_code_tree(code_dir, work_dir)
|
2026-09-15 11:58:17 +08:00
|
|
|
# 模板中 {code_dir} 指向可写副本(而非只读挂载)
|
|
|
|
|
effective_code_dir = work_dir
|
2026-09-14 13:36:18 +08:00
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
parameters = _parse_params(args.param)
|
|
|
|
|
except ExtensionRenderError as exc:
|
|
|
|
|
print(f"[run_extension] {exc}", file=sys.stderr)
|
|
|
|
|
return EXIT_SETUP
|
|
|
|
|
|
|
|
|
|
input_files = sorted(p for p in input_dir.rglob("*") if p.is_file())
|
|
|
|
|
try:
|
|
|
|
|
command_text = render_command_template(
|
|
|
|
|
extension,
|
|
|
|
|
template=template,
|
|
|
|
|
run_dir=work_dir,
|
|
|
|
|
input_dir=input_dir,
|
|
|
|
|
input_files=input_files,
|
|
|
|
|
parameters=parameters,
|
|
|
|
|
output_dir=output_dir,
|
2026-09-15 11:58:17 +08:00
|
|
|
code_dir=effective_code_dir,
|
2026-09-14 13:36:18 +08:00
|
|
|
)
|
|
|
|
|
except ExtensionRenderError as exc:
|
|
|
|
|
print(f"[run_extension] 模板渲染失败: {exc}", file=sys.stderr)
|
|
|
|
|
return EXIT_SETUP
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
command = shlex.split(command_text)
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
print(f"[run_extension] 命令切分失败: {exc}", file=sys.stderr)
|
|
|
|
|
return EXIT_SETUP
|
|
|
|
|
if not command:
|
|
|
|
|
print("[run_extension] 渲染后命令为空", file=sys.stderr)
|
|
|
|
|
return EXIT_SETUP
|
|
|
|
|
|
|
|
|
|
print("[run_extension] command: " + command_text)
|
|
|
|
|
sys.stdout.flush()
|
|
|
|
|
|
|
|
|
|
env = os.environ.copy()
|
|
|
|
|
env.setdefault("PYTHONUNBUFFERED", "1")
|
|
|
|
|
code = _run_command(command, work_dir, env, args.timeout)
|
|
|
|
|
if code == EXIT_TIMEOUT:
|
|
|
|
|
print(f"[run_extension] 执行超时({args.timeout}s),进程组已终止 (exit {EXIT_TIMEOUT})", file=sys.stderr)
|
|
|
|
|
return EXIT_TIMEOUT
|
|
|
|
|
if code != EXIT_OK:
|
|
|
|
|
print(f"[run_extension] command exited with code {code}", file=sys.stderr)
|
|
|
|
|
return code
|
2026-09-15 11:59:42 +08:00
|
|
|
# 收割:work_dir 产物(排除原始代码文件)合并进 output_dir,然后删除 work 目录
|
2026-09-15 11:58:17 +08:00
|
|
|
try:
|
2026-09-15 11:59:42 +08:00
|
|
|
_harvest_outputs(work_dir, output_dir, code_rel_paths)
|
|
|
|
|
import shutil
|
|
|
|
|
shutil.rmtree(work_dir, ignore_errors=True)
|
2026-09-15 11:58:17 +08:00
|
|
|
except OSError as exc:
|
|
|
|
|
print(f"[run_extension] 产物收割失败: {exc}", file=sys.stderr)
|
|
|
|
|
return EXIT_SETUP
|
2026-09-14 13:36:18 +08:00
|
|
|
if not _output_contract_ok(output_dir):
|
|
|
|
|
print(f"[run_extension] 输出契约校验失败:输出目录为空 {output_dir} (exit {EXIT_OUTPUT_CONTRACT})",
|
|
|
|
|
file=sys.stderr)
|
|
|
|
|
return EXIT_OUTPUT_CONTRACT
|
|
|
|
|
return EXIT_OK
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
sys.exit(main())
|