Skip to content

Commit cf8e573

Browse files
committed
fix(cli): write CLI output as UTF-8 under legacy code pages
When stdout or stderr was opened with a non-UTF-8 encoding, such as the Windows cp1252 code page used for pipes and redirects, sqb crashed with E001 on the first non-ASCII character of its summary output. The CLI entry point now reconfigures any non-UTF-8 standard stream to UTF-8 with backslashreplace, so output never fails to encode and JSON output stays valid. Library use and the rule-engine host process are unaffected.
1 parent 69dd1a4 commit cf8e573

10 files changed

Lines changed: 153 additions & 0 deletions

File tree

‎src/sqlbuild/cli/entry/_helpers/__init__.py‎

Whitespace-only changes.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Standard output streams the CLI can always write, whatever the console code page."""
2+
3+
import codecs
4+
import io
5+
import sys
6+
7+
from sqlbuild.cli.entry.constants import OUTPUT_STREAM_ENCODING, OUTPUT_STREAM_ERRORS
8+
9+
10+
def use_utf8_output_streams() -> tuple[bool, ...]:
11+
"""Write stdout and stderr as UTF-8 when opened with another encoding; return which changed."""
12+
13+
return tuple(use_utf8_stream(stream) for stream in (sys.stdout, sys.stderr))
14+
15+
16+
def use_utf8_stream(stream: object) -> bool:
17+
"""Reconfigure a text stream that is not UTF-8 to UTF-8; return whether it changed."""
18+
19+
if not isinstance(stream, io.TextIOWrapper):
20+
return False
21+
if codecs.lookup(stream.encoding).name == codecs.lookup(OUTPUT_STREAM_ENCODING).name:
22+
return False
23+
stream.reconfigure(encoding=OUTPUT_STREAM_ENCODING, errors=OUTPUT_STREAM_ERRORS)
24+
return True

‎src/sqlbuild/cli/entry/constants.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@
22

33
NATIVE_ALLOCATOR_PURGE_DELAY_VARIABLE: str = "MIMALLOC_PURGE_DELAY"
44
NATIVE_ALLOCATOR_PURGE_DELAY_MILLISECONDS: str = "10"
5+
OUTPUT_STREAM_ENCODING: str = "utf-8"
6+
OUTPUT_STREAM_ERRORS: str = "backslashreplace"

‎src/sqlbuild/cli/entry/main/entry.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
from collections.abc import Sequence
55

6+
from sqlbuild.cli.entry._helpers.streams import use_utf8_output_streams
67
from sqlbuild.cli.entry.constants import (
78
NATIVE_ALLOCATOR_PURGE_DELAY_MILLISECONDS,
89
NATIVE_ALLOCATOR_PURGE_DELAY_VARIABLE,
@@ -15,6 +16,7 @@ def main(argv: Sequence[str] | None = None) -> int:
1516
os.environ.setdefault(
1617
NATIVE_ALLOCATOR_PURGE_DELAY_VARIABLE, NATIVE_ALLOCATOR_PURGE_DELAY_MILLISECONDS
1718
)
19+
_ = use_utf8_output_streams()
1820
from sqlbuild.cli.commands.main.entrypoint.entry import main as run_cli
1921

2022
return run_cli(argv)

‎tests/e2e/src/sqlbuild/cli/commands/main/playground/_test_types.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,11 @@ class UnicodeEmptyFixtureTestCase:
2323
description: str
2424
status: str
2525
expected_added_findings: int = 0
26+
27+
28+
@dataclass(frozen=True)
29+
class LegacyCodePageOutputTestCase:
30+
description: str
31+
code_page: str
32+
expected_summary: str
33+
expected_json_command: str
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import subprocess
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
from sqlbuild.cli.commands.main.workspace._playground import run_playground
10+
from sqlbuild.cli.commands.models import PlaygroundCommandRequest
11+
from tests.e2e.src.sqlbuild.cli.commands.main.playground._test_types import (
12+
LegacyCodePageOutputTestCase,
13+
)
14+
from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import run_sqb
15+
16+
17+
@pytest.mark.parametrize(
18+
"test_case",
19+
[
20+
LegacyCodePageOutputTestCase(
21+
description="cp1252 streams still get the UTF-8 summary and valid JSON",
22+
code_page="cp1252",
23+
expected_summary="\u2713 Project compiled",
24+
expected_json_command="compile",
25+
),
26+
],
27+
ids=lambda case: case.description,
28+
)
29+
def test_given_legacy_code_page_streams_when_compiling_then_output_is_utf8(
30+
test_case: LegacyCodePageOutputTestCase, tmp_path: Path
31+
) -> None:
32+
assert run_playground(PlaygroundCommandRequest(project_dir=tmp_path, target_path="shop")) == 0
33+
environment: dict[str, str] = {"PYTHONIOENCODING": test_case.code_page}
34+
35+
summary: subprocess.CompletedProcess[str] = run_sqb(
36+
command=("--no-color", "compile"), project_dir=tmp_path / "shop", env=environment
37+
)
38+
machine: subprocess.CompletedProcess[str] = run_sqb(
39+
command=("compile", "--json"), project_dir=tmp_path / "shop", env=environment
40+
)
41+
42+
assert (
43+
summary.returncode,
44+
test_case.expected_summary in summary.stdout,
45+
machine.returncode,
46+
json.loads(machine.stdout).get("command"),
47+
) == (0, True, 0, test_case.expected_json_command), summary.stderr + machine.stderr
48+
49+
50+
if __name__ == "__main__":
51+
pytest.main([__file__, "-vv"])

‎tests/unit/src/sqlbuild/cli/entry/__init__.py‎

Whitespace-only changes.

‎tests/unit/src/sqlbuild/cli/entry/_helpers/__init__.py‎

Whitespace-only changes.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from dataclasses import dataclass
2+
3+
4+
@dataclass(frozen=True)
5+
class OutputStreamEncodingTestCase:
6+
description: str
7+
encoding: str
8+
errors: str
9+
text: str
10+
expected_changed: bool
11+
expected_bytes: bytes
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import io
2+
3+
import pytest
4+
5+
from sqlbuild.cli.entry._helpers.streams import use_utf8_stream
6+
from tests.unit.src.sqlbuild.cli.entry._helpers._test_types import OutputStreamEncodingTestCase
7+
8+
9+
@pytest.mark.parametrize(
10+
"test_case",
11+
[
12+
OutputStreamEncodingTestCase(
13+
description="a cp1252 stream writes box drawing and symbols as UTF-8",
14+
encoding="cp1252",
15+
errors="strict",
16+
text="\u2713 Project compiled \u2500\u2500 orders \u2192 customers\n",
17+
expected_changed=True,
18+
expected_bytes="\u2713 Project compiled \u2500\u2500 orders \u2192 customers\n".encode(),
19+
),
20+
OutputStreamEncodingTestCase(
21+
description="a lone surrogate is escaped instead of crashing",
22+
encoding="cp1252",
23+
errors="strict",
24+
text="orders\ud800.sql\n",
25+
expected_changed=True,
26+
expected_bytes=b"orders\\ud800.sql\n",
27+
),
28+
OutputStreamEncodingTestCase(
29+
description="a UTF-8 stream under another spelling is left alone",
30+
encoding="UTF8",
31+
errors="backslashreplace",
32+
text="\u2713 orders\n",
33+
expected_changed=False,
34+
expected_bytes="\u2713 orders\n".encode(),
35+
),
36+
],
37+
ids=lambda case: case.description,
38+
)
39+
def test_given_stream_encoding_when_preparing_cli_output_then_any_text_is_written(
40+
test_case: OutputStreamEncodingTestCase,
41+
) -> None:
42+
buffer: io.BytesIO = io.BytesIO()
43+
stream: io.TextIOWrapper = io.TextIOWrapper(
44+
buffer, encoding=test_case.encoding, errors=test_case.errors
45+
)
46+
47+
changed: bool = use_utf8_stream(stream)
48+
_ = stream.write(test_case.text)
49+
stream.flush()
50+
51+
assert (changed, buffer.getvalue()) == (test_case.expected_changed, test_case.expected_bytes)
52+
53+
54+
if __name__ == "__main__":
55+
pytest.main([__file__, "-vv"])

0 commit comments

Comments
 (0)