Skip to content

Commit 9dffd2d

Browse files
authored
fix(scripts): write --json output of the Python helpers as ASCII (#5048)
* fix(scripts): write --json output of the Python helpers as ASCII With --script py the command templates read the helpers' JSON from a captured stdout. On Windows that stream uses the ANSI code page, and ensure_ascii=False made setup_plan, check_prerequisites, setup_tasks and resolve_template --json raise UnicodeEncodeError and print nothing for the stock constitution and tasks templates and for non-Latin feature names. #3890 and #3892 fixed the text output of two of them; this covers the JSON paths. Callers parse the JSON, so the decoded values are the same. Assisted-by: Codex CLI (model: gpt-6.1-sol, autonomous) Assisted-by: Claude Code (model: claude-opus-5-5, autonomous) * test(scripts): compare setup_plan's copied plan with the on-disk template The fixture writes plan-template.md with write_text(), which gives CRLF on Windows, and setup_plan copies those bytes. Comparing against the LF constant failed on Windows even with the JSON fix in place. Assisted-by: Claude Code (model: claude-opus-5-5, autonomous)
1 parent c06e670 commit 9dffd2d

8 files changed

Lines changed: 104 additions & 5 deletions

‎scripts/python/check_prerequisites.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828

2929

3030
def _json_line(payload: object) -> str:
31-
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
31+
# ASCII escapes keep the JSON intact on a legacy Windows stdout code page.
32+
return json.dumps(payload, ensure_ascii=True, separators=(",", ":")) + "\n"
3233

3334

3435
HELP_TEXT = """Usage: check_prerequisites.py [OPTIONS]

‎scripts/python/resolve_template.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ def main(argv: list[str] | None = None) -> int:
5050
"TEMPLATE_NAME": args.template_name,
5151
"TEMPLATE_CONTENT": content,
5252
},
53-
ensure_ascii=False,
53+
# ASCII escapes keep the JSON intact on a legacy Windows stdout code page.
54+
ensure_ascii=True,
5455
separators=(",", ":"),
5556
)
5657
)

‎scripts/python/setup_plan.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323

2424

2525
def _json_line(payload: object) -> str:
26-
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
26+
# ASCII escapes keep the JSON intact on a legacy Windows stdout code page.
27+
return json.dumps(payload, ensure_ascii=True, separators=(",", ":")) + "\n"
2728

2829

2930
def _help_text(argv0: str) -> str:

‎scripts/python/setup_tasks.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929

3030

3131
def _json_line(payload: object) -> str:
32-
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
32+
# ASCII escapes keep the JSON intact on a legacy Windows stdout code page.
33+
return json.dumps(payload, ensure_ascii=True, separators=(",", ":")) + "\n"
3334

3435

3536
def _help_text(argv0: str) -> str:

‎tests/test_check_prerequisites_python_parity.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import pytest
1313

1414
from tests.conftest import requires_bash
15-
from tests.parity_helpers import install_composition_stack
15+
from tests.parity_helpers import install_composition_stack, run
1616

1717
PROJECT_ROOT = Path(__file__).resolve().parent.parent
1818
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
@@ -122,6 +122,24 @@ def _json_stdout(result: subprocess.CompletedProcess[str]) -> object:
122122
return json.loads(result.stdout)
123123

124124

125+
def test_python_json_preserves_non_ascii_feature_on_legacy_stdout(
126+
prereq_repo: Path,
127+
) -> None:
128+
feature = prereq_repo / "specs/001-功能"
129+
feature.mkdir(parents=True)
130+
(feature / "plan.md").write_bytes(b"# Plan\r\n")
131+
_write_feature_json(prereq_repo, "specs/001-功能")
132+
env = _clean_env()
133+
env["PYTHONIOENCODING"] = "cp1252"
134+
135+
result = run(_py_cmd(prereq_repo, "--json"), prereq_repo, env)
136+
137+
assert result.returncode == 0, result.stderr
138+
payload = _json_stdout(result)
139+
assert Path(payload["FEATURE_DIR"]) == feature
140+
assert payload["AVAILABLE_DOCS"] == []
141+
142+
125143
def _normalize_status_text(text: str) -> str:
126144
return (
127145
text.replace(" ✓ ", " [OK] ")

‎tests/test_resolve_template_python_parity.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,25 @@
3232
TEMPLATE = "constitution-template"
3333

3434

35+
def test_python_json_preserves_constitution_on_legacy_stdout(tmp_path: Path) -> None:
36+
repo = make_repo(tmp_path)
37+
install_scripts(repo, SCRIPT)
38+
template = repo / ".specify/templates/overrides/constitution-template.md"
39+
template.parent.mkdir(parents=True)
40+
expected = "# Конституція\n\nПравила: 功能 → звіт.\n"
41+
template.write_bytes(expected.encode("utf-8"))
42+
env = clean_env()
43+
env["PYTHONIOENCODING"] = "cp1252"
44+
45+
result = run(py_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env)
46+
47+
assert result.returncode == 0, result.stderr
48+
assert json_stdout(result) == {
49+
"TEMPLATE_NAME": TEMPLATE, "TEMPLATE_CONTENT": expected,
50+
}
51+
assert template.read_bytes() == expected.encode("utf-8")
52+
53+
3554
def _setup_repo(tmp_path: Path) -> tuple[Path, str]:
3655
repo = make_repo(tmp_path)
3756
install_scripts(repo, SCRIPT)

‎tests/test_setup_plan_python_parity.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,25 @@ def repo(tmp_path: Path) -> Path:
6060
return _setup_repo(tmp_path)
6161

6262

63+
def test_python_json_preserves_non_ascii_feature_on_legacy_stdout(repo: Path) -> None:
64+
feature = repo / "specs/001-功能"
65+
feature.mkdir()
66+
write_feature_json(repo, "specs/001-功能")
67+
env = clean_env()
68+
env["PYTHONIOENCODING"] = "cp1252"
69+
70+
result = run(py_cmd(repo, SCRIPT, "--json"), repo, env)
71+
72+
assert result.returncode == 0, result.stderr
73+
payload = json_stdout(result)
74+
assert payload["BRANCH"] == "001-功能"
75+
assert Path(payload["FEATURE_DIR"]) == feature
76+
assert Path(payload["FEATURE_SPEC"]) == feature / "spec.md"
77+
assert Path(payload["IMPL_PLAN"]) == feature / "plan.md"
78+
template = repo / ".specify" / "templates" / "plan-template.md"
79+
assert (feature / "plan.md").read_bytes() == template.read_bytes()
80+
81+
6382
@pytest.mark.parametrize("port", PORTS)
6483
@pytest.mark.parametrize("json_mode", [True, False], ids=["json", "text"])
6584
@pytest.mark.parametrize("template_state", ["missing", "empty", "empty_override"])

‎tests/test_setup_tasks_python_parity.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,45 @@
2626
SCRIPT = "setup-tasks"
2727

2828

29+
def test_installed_python_json_preserves_stock_tasks_template(
30+
tmp_path: Path,
31+
) -> None:
32+
"""The tasks command's actual script must emit JSON for the stock template."""
33+
from typer.testing import CliRunner
34+
35+
from specify_cli import app
36+
37+
project = tmp_path / "project"
38+
initialized = CliRunner().invoke(
39+
app,
40+
[
41+
"init", str(project), "--integration", "codex", "--script", "py",
42+
"--ignore-agent-tools", "--non-interactive",
43+
],
44+
)
45+
assert initialized.exit_code == 0, initialized.output
46+
created = run(
47+
py_cmd(project, "create-new-feature", "--json", "Add user authentication"),
48+
project,
49+
)
50+
assert created.returncode == 0, created.stderr
51+
plan = run(py_cmd(project, "setup-plan", "--json"), project)
52+
assert plan.returncode == 0, plan.stderr
53+
template = project / ".specify/templates/tasks-template.md"
54+
expected = template.read_bytes().decode("utf-8")
55+
assert "🎯" in expected
56+
env = clean_env()
57+
env["PYTHONIOENCODING"] = "cp1252"
58+
59+
result = run(py_cmd(project, SCRIPT, "--json"), project, env)
60+
61+
assert result.returncode == 0, result.stderr
62+
payload = json_stdout(result)
63+
assert payload["TASKS_TEMPLATE_CONTENT"] == expected
64+
assert Path(payload["TASKS_TEMPLATE"]) == template
65+
assert payload["AVAILABLE_DOCS"] == []
66+
67+
2968
def _setup_repo(tmp_path: Path) -> Path:
3069
repo = make_repo(tmp_path)
3170
install_scripts(repo, SCRIPT)

0 commit comments

Comments
 (0)