Skip to content

Commit 80cc5b7

Browse files
authored
DEVOPS-751 fix: improve stream handling in sfdx for Windows compatibility (#10)
* DEVOPS-751 fix: improve stream handling in sfdx for Windows compatibility * DEVOPS-751 chore: refine error handling in _capture_to_text_stream for better robustness * DEVOPS-751 style: remove unnecessary blank line in _capture_to_text_stream function
1 parent 778e420 commit 80cc5b7

2 files changed

Lines changed: 58 additions & 2 deletions

File tree

cumulusci/core/sfdx.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,41 @@
1919
logger = logging.getLogger(__name__)
2020

2121

22+
def _capture_to_text_stream(stream: T.Any, encoding: str) -> io.StringIO:
23+
"""Convert a capture stream into a readable text buffer.
24+
25+
sarge's Capture objects do not always implement ``flush`` on Windows,
26+
which breaks ``io.TextIOWrapper``. This helper normalizes the stream
27+
into an in-memory text buffer that supports the APIs the rest of CCI
28+
expects (``read``, iteration, etc.) without relying on ``flush``.
29+
"""
30+
31+
if stream is None:
32+
return io.StringIO("")
33+
34+
data: T.Any = None
35+
getter = getattr(stream, "getvalue", None)
36+
if callable(getter):
37+
try:
38+
data = getter()
39+
except (AttributeError, IOError, OSError, ValueError):
40+
data = None
41+
if data is None:
42+
reader = getattr(stream, "read", None)
43+
if callable(reader):
44+
try:
45+
data = reader()
46+
except (AttributeError, IOError, OSError, ValueError):
47+
data = None
48+
49+
if data is None:
50+
return io.StringIO("")
51+
if isinstance(data, str):
52+
return io.StringIO(data)
53+
if isinstance(data, (bytes, bytearray)):
54+
return io.StringIO(data.decode(encoding, errors="replace"))
55+
return io.StringIO(str(data))
56+
2257
def sfdx(
2358
command,
2459
username=None,
@@ -56,8 +91,9 @@ def sfdx(
5691
)
5792
p.run()
5893
if capture_output:
59-
p.stdout_text = io.TextIOWrapper(p.stdout, encoding=sys.stdout.encoding)
60-
p.stderr_text = io.TextIOWrapper(p.stderr, encoding=sys.stdout.encoding)
94+
encoding = sys.stdout.encoding or sys.getdefaultencoding() or "utf-8"
95+
p.stdout_text = _capture_to_text_stream(p.stdout, encoding)
96+
p.stderr_text = _capture_to_text_stream(p.stderr, encoding)
6197
if check_return and p.returncode:
6298
message = f"Command exited with return code {p.returncode}"
6399
if capture_output:

cumulusci/core/tests/test_sfdx.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,26 @@ def test_check_return(self, Command):
4646
sfdx("cmd", check_return=True)
4747
assert str(exc_info.value) == "Command exited with return code 1:\nEgads!"
4848

49+
@mock.patch("sarge.Command")
50+
def test_capture_without_flush_is_wrapped(self, Command):
51+
class DummyCapture:
52+
def __init__(self, payload):
53+
self._payload = payload
54+
55+
def read(self, *_args, **_kwargs):
56+
return self._payload
57+
58+
Command.return_value.returncode = 0
59+
Command.return_value.stdout = DummyCapture(b"ok")
60+
Command.return_value.stderr = DummyCapture(b"")
61+
62+
result = sfdx("cmd")
63+
64+
assert isinstance(result.stdout_text, io.StringIO)
65+
assert result.stdout_text.read() == "ok"
66+
result.stdout_text.seek(0)
67+
assert list(result.stdout_text) == ["ok"]
68+
4969

5070
@mock.patch("sarge.Command")
5171
def test_get_default_devhub_username(Command):

0 commit comments

Comments
 (0)