Fix Windows path escape in generated Blender render script (#390)

Use repr() (via f-string !r) for path embedding instead of raw-string
r'...' prefixes. Raw strings are fragile with trailing backslashes and
quotes in paths; repr() produces a correctly-escaped Python literal
for any path.

Affects three sites in bpy_gen.py:
- HDRI image path in _gen_world_settings
- Output filepath in _gen_render_output
- 'Render complete:' print statement in _gen_render_output

Fixes #343
This commit is contained in:
ClumsyLucid
2026-08-03 16:38:55 +08:00
committed by GitHub
parent d71d4a240b
commit b7451aff88
2 changed files with 123 additions and 3 deletions
@@ -0,0 +1,120 @@
"""Tests for bpy_gen.py — Blender Python script generation.
Tests verify that generated scripts are syntactically valid Python,
especially with Windows-style paths that could trigger escape errors.
"""
from typing import Any, Dict
import pytest
from cli_anything.blender.utils.bpy_gen import generate_full_script
def _minimal_project() -> Dict[str, Any]:
"""Return a minimal project dict sufficient for script generation."""
return {
"version": "1.0",
"scene": {
"unit_system": "METRIC",
"unit_scale": 1.0,
"frame_start": 1,
"frame_end": 250,
"frame_current": 1,
"fps": 24,
},
"render": {
"engine": "CYCLES",
"resolution_x": 1920,
"resolution_y": 1080,
"resolution_percentage": 100,
"samples": 128,
"use_denoising": True,
"output_format": "PNG",
},
"objects": [],
"cameras": [],
"lights": [],
"materials": [],
"world": {
"color": [0.0, 0.0, 0.0],
"use_hdri": False,
},
"keyframes": [],
}
class TestGeneratedScriptSyntax:
"""Generated scripts must produce valid Python, even on Windows."""
@pytest.mark.parametrize("output_path", [
r"C:\Users\user\Desktop\render.png",
r"D:\data\scene\output.png",
r"\\server\share\render_0001.png",
r"C:\Users\test\AppData\Local\file.png",
"/home/user/render.png",
"./relative/render.png",
])
def test_windows_paths_produce_valid_python(self, output_path: str) -> None:
"""Windows-style backslash paths must not cause SyntaxError."""
project = _minimal_project()
script = generate_full_script(project, output_path)
# compile() will raise SyntaxError if the script is invalid
try:
compile(script, "<test>", "exec")
except SyntaxError as e:
pytest.fail(
f"Generated script has SyntaxError with path {output_path!r}:\n"
f"{e}\n"
f"--- script excerpt around error ---\n"
f"{_excerpt(e, script)}"
)
def test_trailing_backslash_path(self) -> None:
"""Paths ending in backslash must be handled correctly (raw-string edge case)."""
project = _minimal_project()
script = generate_full_script(project, "C:\\Users\\user\\output_dir\\")
compile(script, "<test>", "exec")
def test_path_with_quotes(self) -> None:
"""Paths containing single quotes must not break generated script."""
project = _minimal_project()
script = generate_full_script(project, "C:\\Users\\o'brien\\output.png")
compile(script, "<test>", "exec")
def test_hdri_path_with_windows_slashes(self) -> None:
"""HDRI paths on Windows must not cause SyntaxError."""
project = _minimal_project()
project["world"]["use_hdri"] = True
project["world"]["hdri_path"] = r"C:\Users\user\assets\env.hdr"
project["world"]["hdri_strength"] = 1.0
script = generate_full_script(project, r"C:\Users\user\output.png")
compile(script, "<test>", "exec")
def test_print_statement_with_windows_path(self) -> None:
"""The 'Render complete:' print line must not cause Unicode escape error."""
project = _minimal_project()
script = generate_full_script(project, r"C:\Users\u\output.png")
compile(script, "<test>", "exec")
assert "Render complete:" in script
def test_normal_unix_paths_still_work(self) -> None:
"""Existing Unix path behavior must not regress."""
project = _minimal_project()
script = generate_full_script(project, "/tmp/render.png")
compile(script, "<test>", "exec")
def _excerpt(err: SyntaxError, script: str) -> str:
"""Return a short excerpt of the script around the syntax error."""
lines = script.splitlines()
lineno = err.lineno or 1
start = max(0, lineno - 3)
end = min(len(lines), lineno + 2)
excerpt = lines[start:end]
result = f" Line {start + 1} to {end}:\n"
for i, line in enumerate(excerpt, start=start + 1):
marker = " >>> " if i == lineno else " "
result += f"{marker}{i}: {line}\n"
return result
@@ -144,7 +144,7 @@ def _gen_world_settings(project: Dict[str, Any]) -> List[str]:
"",
"# HDRI environment",
"env_tex = world.node_tree.nodes.new('ShaderNodeTexEnvironment')",
f"env_tex.image = bpy.data.images.load(r'{hdri_path}')",
f"env_tex.image = bpy.data.images.load({hdri_path!r})",
f"bg_node.inputs[1].default_value = {strength}",
"world.node_tree.links.new(env_tex.outputs[0], bg_node.inputs[0])",
])
@@ -532,7 +532,7 @@ def _gen_render_output(
lines = [
"# ── Render Output ───────────────────────────────────────────",
f"scene.render.image_settings.file_format = '{bpy_format}'",
f"scene.render.filepath = r'{output_path}'",
f"scene.render.filepath = {output_path!r}",
]
if animation:
@@ -552,7 +552,7 @@ def _gen_render_output(
lines.extend([
"",
f"print('Render complete: {output_path}')",
f"print('Render complete: ' + {output_path!r})",
])
return lines