diff --git a/gfa_core/extension_stage_lightweight/runtime_binding.py b/gfa_core/extension_stage_lightweight/runtime_binding.py index 2478c101f..135a6d240 100644 --- a/gfa_core/extension_stage_lightweight/runtime_binding.py +++ b/gfa_core/extension_stage_lightweight/runtime_binding.py @@ -1536,6 +1536,20 @@ def _shell_word_pattern(literal: str) -> re.Pattern[str]: ) +def _shell_word_replacement(replacement: str) -> Callable[[re.Match[str]], str]: + """替换函数:保留原文中字面量自带的引号。 + + ``shlex.quote`` 在 shell 上下文是正确的,但 ``python -c "...'a.fa'..."`` 里的 + 字符串字面量替换后必须保留引号,否则会变成裸标识符导致 NameError。 + """ + + def _replace(match: re.Match[str]) -> str: + quote = match.group("quote") or "" + return f"{quote}{replacement}{quote}" + + return _replace + + def _script_positional_args(contract: dict[str, Any], script: str) -> list[str]: try: tokens = shlex.split(str(contract.get("original_command") or "")) @@ -1710,7 +1724,7 @@ def _render_script_updates( "PATCH_TARGET_INVALID", f"invalid match count for {literal}" ) updated, count = _shell_word_pattern(literal).subn( - shlex.quote(replacement), text + _shell_word_replacement(replacement), text ) if count < expected: raise RuntimeBindingError( diff --git a/tests/unit/test_extension_stage_lightweight_runtime_package.py b/tests/unit/test_extension_stage_lightweight_runtime_package.py index fd46309d2..4015f0ef0 100644 --- a/tests/unit/test_extension_stage_lightweight_runtime_package.py +++ b/tests/unit/test_extension_stage_lightweight_runtime_package.py @@ -1528,3 +1528,31 @@ def test_replay_isolates_symlink_input_before_source_overwrite( } ] assert result["execution_work_tree"]["Ft.input.txt"]["type"] == "regular" + + +def test_shell_word_replacement_preserves_python_string_literal_quotes() -> None: + """zh01 回归:python -c 里带引号的输出路径被前缀替换后必须保留引号。 + + 替换前:SeqIO.write(...,'combined.pep.fasta','fasta') + 若用 shlex.quote 替换会变成 SeqIO.write(...,output.fasta,'fasta') + → Python 报 NameError: name 'output' is not defined。 + """ + pattern = runtime_module._shell_word_pattern("combined.pep.fasta") + replace = runtime_module._shell_word_replacement("output.fasta") + source = ( + "python -c \"from Bio import SeqIO; " + "SeqIO.write(list(SeqIO.parse('Ft.SPL.pep.fasta','fasta'))," + "'combined.pep.fasta','fasta')\" && " + "muscle -in combined.pep.fasta -out combined.pep.mfa" + ) + updated, count = pattern.subn(replace, source) + assert count == 2 + assert "'output.fasta'" in updated + assert "'combined.pep.fasta'" not in updated + # 无引号的 shell 单词路径不受影响(不引入引号) + shell_text = "muscle -in combined.pep.mfa -out combined.pep.mfa2" + updated_shell, shell_count = runtime_module._shell_word_pattern( + "combined.pep.mfa" + ).subn(runtime_module._shell_word_replacement("output.mfa"), shell_text) + assert shell_count == 1 + assert "-in output.mfa" in updated_shell