Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions bootstrap/launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,28 @@
VERSION_PATTERN = re.compile(r"""^__version__\s*=\s*['"]([^'"]+)['"]""", re.MULTILINE)


def _configure_stdio() -> None:
"""Let a cp1252 Windows console print every character the child emits.

Hosted Windows runners start Python with the console code page, usually
cp1252. Child output is decoded with ``errors="replace"``, so a relayed
line can contain U+FFFD. Writing that character through a cp1252
``sys.stdout`` raises ``UnicodeEncodeError`` and aborts the action.
UTF-8 encodes it.

A missing stream, a redirect that has no ``reconfigure``, or an
``OSError`` / ``ValueError`` from the stream must not abort startup.
"""
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is None:
continue
try:
reconfigure(encoding="utf-8", errors="replace")
except (OSError, ValueError):
continue


def _env(name: str, default: str = "") -> str:
return os.environ.get(name, default).strip()

Expand Down Expand Up @@ -82,6 +104,9 @@ def packaged_version(checkout: Path) -> str:


def main() -> int:
# Before any print or relayed child line. The crash is a cp1252 stdout
# refusing U+FFFD; every later write goes through these two streams.
_configure_stdio()
repo = Path(_env("SKILLSCOPE_REPO", ".") or ".").expanduser().resolve()
command = _env("SKILLSCOPE_COMMAND")
if not command:
Expand Down
67 changes: 67 additions & 0 deletions tests/test_skillscope.py
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,73 @@ def test_it_installs_from_the_action_checkout(self) -> None:
with mock.patch.dict(os.environ, {"SKILLSCOPE_ACTION_PATH": str(root)}):
self.assertEqual(self.module.action_root(), root)

def test_a_cp1252_console_can_relay_the_replacement_character(self) -> None:
# Child stdout is decoded with errors="replace", so U+FFFD reaches
# sys.stdout.write. A cp1252 console raises UnicodeEncodeError there
# unless startup reconfigured the stream to UTF-8.
buffer = io.BytesIO()
stdout = io.TextIOWrapper(buffer, encoding="cp1252", errors="strict")
stderr = io.TextIOWrapper(io.BytesIO(), encoding="cp1252", errors="strict")
child = io.StringIO("hot kernel \ufffd\n")

class FakeProc:
def __init__(self) -> None:
self.stdout = child

def wait(self) -> int:
return 0

repo_root = Path(__file__).resolve().parent.parent
try:
with tempfile.TemporaryDirectory() as tmp:
with (
mock.patch.object(self.module.sys, "stdout", stdout),
mock.patch.object(self.module.sys, "stderr", stderr),
mock.patch.object(self.module.subprocess, "Popen", return_value=FakeProc()),
mock.patch.dict(
os.environ,
{
"SKILLSCOPE_COMMAND": "behavioral",
"SKILLSCOPE_ARGS": "",
"SKILLSCOPE_STDIN": "",
"SKILLSCOPE_REPO": tmp,
"SKILLSCOPE_ACTION_PATH": str(repo_root),
"GITHUB_OUTPUT": "",
"GITHUB_STEP_SUMMARY": "",
},
),
):
code = self.module.main()
self.assertEqual(code, 0)
self.assertEqual(stdout.encoding, "utf-8")
self.assertEqual(stdout.errors, "replace")
self.assertEqual(stderr.encoding, "utf-8")
self.assertIn("\ufffd".encode("utf-8"), buffer.getvalue())
finally:
stdout.close()
stderr.close()

def test_stdio_setup_survives_a_stream_that_cannot_be_reconfigured(self) -> None:
class RaisesOSError:
def reconfigure(self, **_kwargs: object) -> None:
raise OSError("console code page is locked")

closed = io.TextIOWrapper(io.BytesIO(), encoding="cp1252")
closed.close()
cases = (
(None, None),
(io.StringIO(), io.StringIO()),
(RaisesOSError(), RaisesOSError()),
(closed, closed),
)
for stdout, stderr in cases:
with self.subTest(stdout=type(stdout).__name__):
with (
mock.patch.object(self.module.sys, "stdout", stdout),
mock.patch.object(self.module.sys, "stderr", stderr),
):
self.module._configure_stdio()


class TestSelection(unittest.TestCase):
"""What CI runs for a change, and what it is right to skip."""
Expand Down
Loading