Skip to content

Commit e5f8721

Browse files
authored
Merge pull request #96 from UiPath/fix/interceptor-detach-stdout-buffer
fix: use detach() to preserve stdout buffer on teardown
2 parents 0fb9be2 + 04562e6 commit e5f8721

File tree

4 files changed

+177
-5
lines changed

4 files changed

+177
-5
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-runtime"
3-
version = "0.9.1"
3+
version = "0.9.2"
44
description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath/runtime/logging/_interceptor.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233,12 +233,13 @@ def teardown(self) -> None:
233233
if handler not in self.root_logger.handlers:
234234
self.root_logger.addHandler(handler)
235235

236+
if hasattr(self, "utf8_stdout"):
237+
self.utf8_stdout.detach()
238+
del self.utf8_stdout
239+
236240
if self._owns_handler:
237241
self.log_handler.close()
238242

239-
if hasattr(self, "utf8_stdout"):
240-
self.utf8_stdout.close()
241-
242243
# Only restore streams if we redirected them
243244
if self.original_stdout and self.original_stderr:
244245
sys.stdout = self.original_stdout

tests/test_interceptor.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""Tests for UiPathRuntimeLogsInterceptor teardown with non-UTF-8 stdout."""
2+
3+
import io
4+
import logging
5+
import sys
6+
from unittest.mock import patch
7+
8+
import pytest
9+
10+
from uipath.runtime.logging._interceptor import UiPathRuntimeLogsInterceptor
11+
12+
13+
@pytest.fixture(autouse=True)
14+
def _isolate_logging():
15+
"""Save and restore logging state so tests don't leak into each other."""
16+
root = logging.getLogger()
17+
original_level = root.level
18+
original_handlers = list(root.handlers)
19+
original_stdout = sys.stdout
20+
original_stderr = sys.stderr
21+
yield
22+
root.setLevel(original_level)
23+
root.handlers = original_handlers
24+
sys.stdout = original_stdout
25+
sys.stderr = original_stderr
26+
logging.disable(logging.NOTSET)
27+
28+
29+
def _make_cp1252_stdout() -> io.TextIOWrapper:
30+
"""Create a TextIOWrapper that mimics Windows cp1252 piped stdout."""
31+
raw_buffer = io.BytesIO()
32+
return io.TextIOWrapper(raw_buffer, encoding="cp1252", line_buffering=True)
33+
34+
35+
class TestInterceptorTeardownPreservesBuffer:
36+
"""Verify that teardown does not destroy the underlying stdout buffer."""
37+
38+
def test_buffer_usable_after_teardown(self):
39+
"""After setup+teardown the original stdout buffer must still be writable."""
40+
fake_stdout = _make_cp1252_stdout()
41+
42+
with (
43+
patch.object(sys, "stdout", fake_stdout),
44+
patch.object(sys, "stderr", fake_stdout),
45+
):
46+
interceptor = UiPathRuntimeLogsInterceptor(job_id=None)
47+
48+
# The wrapper should have been created because encoding is cp1252
49+
assert hasattr(interceptor, "utf8_stdout")
50+
51+
interceptor.setup()
52+
interceptor.teardown()
53+
54+
# The underlying buffer must still be open and writable
55+
assert not fake_stdout.buffer.closed
56+
fake_stdout.buffer.write(b"still alive")
57+
58+
def test_no_valueerror_writing_after_teardown(self):
59+
"""Writing to the original stdout after teardown must not raise ValueError."""
60+
fake_stdout = _make_cp1252_stdout()
61+
62+
with (
63+
patch.object(sys, "stdout", fake_stdout),
64+
patch.object(sys, "stderr", fake_stdout),
65+
):
66+
interceptor = UiPathRuntimeLogsInterceptor(job_id=None)
67+
interceptor.setup()
68+
interceptor.teardown()
69+
70+
# This simulates what click.echo() does — write to the restored stdout
71+
fake_stdout.write("no crash")
72+
fake_stdout.flush()
73+
74+
def test_utf8_stdout_not_created_for_utf8_encoding(self):
75+
"""When stdout is already UTF-8, no wrapper should be created."""
76+
utf8_stdout = io.TextIOWrapper(
77+
io.BytesIO(), encoding="utf-8", line_buffering=True
78+
)
79+
80+
with (
81+
patch.object(sys, "stdout", utf8_stdout),
82+
patch.object(sys, "stderr", utf8_stdout),
83+
):
84+
interceptor = UiPathRuntimeLogsInterceptor(job_id=None)
85+
86+
assert not hasattr(interceptor, "utf8_stdout")
87+
88+
def test_utf8_stdout_attr_removed_after_teardown(self):
89+
"""After teardown, the utf8_stdout attribute should be deleted (double-teardown guard)."""
90+
fake_stdout = _make_cp1252_stdout()
91+
92+
with (
93+
patch.object(sys, "stdout", fake_stdout),
94+
patch.object(sys, "stderr", fake_stdout),
95+
):
96+
interceptor = UiPathRuntimeLogsInterceptor(job_id=None)
97+
interceptor.setup()
98+
interceptor.teardown()
99+
100+
assert not hasattr(interceptor, "utf8_stdout")
101+
102+
def test_double_teardown_does_not_raise(self):
103+
"""Calling teardown twice must not raise (guarded by del)."""
104+
fake_stdout = _make_cp1252_stdout()
105+
106+
with (
107+
patch.object(sys, "stdout", fake_stdout),
108+
patch.object(sys, "stderr", fake_stdout),
109+
):
110+
interceptor = UiPathRuntimeLogsInterceptor(job_id=None)
111+
interceptor.setup()
112+
interceptor.teardown()
113+
# Second teardown should be safe
114+
interceptor.teardown()
115+
116+
117+
class TestInterceptorTeardownOrder:
118+
"""Verify that detach happens before handler close."""
119+
120+
def test_detach_called_before_handler_close(self):
121+
"""utf8_stdout.detach() must execute before log_handler.close()."""
122+
fake_stdout = _make_cp1252_stdout()
123+
call_order: list[str] = []
124+
125+
with (
126+
patch.object(sys, "stdout", fake_stdout),
127+
patch.object(sys, "stderr", fake_stdout),
128+
):
129+
interceptor = UiPathRuntimeLogsInterceptor(job_id=None)
130+
assert hasattr(interceptor, "utf8_stdout")
131+
132+
# Wrap detach and close to record call order
133+
original_detach = interceptor.utf8_stdout.detach
134+
original_close = interceptor.log_handler.close
135+
136+
def tracked_detach():
137+
call_order.append("detach")
138+
return original_detach()
139+
140+
def tracked_close():
141+
call_order.append("handler_close")
142+
return original_close()
143+
144+
with (
145+
patch.object(interceptor.utf8_stdout, "detach", tracked_detach),
146+
patch.object(interceptor.log_handler, "close", tracked_close),
147+
):
148+
interceptor.setup()
149+
interceptor.teardown()
150+
151+
assert "detach" in call_order
152+
assert "handler_close" in call_order
153+
assert call_order.index("detach") < call_order.index("handler_close")
154+
155+
156+
class TestInterceptorWithJobId:
157+
"""When job_id is set, a file handler is used — no utf8_stdout wrapper."""
158+
159+
def test_no_utf8_wrapper_with_job_id(self, tmp_path):
160+
"""File-based handler path should never create utf8_stdout."""
161+
fake_stdout = _make_cp1252_stdout()
162+
163+
with (
164+
patch.object(sys, "stdout", fake_stdout),
165+
patch.object(sys, "stderr", fake_stdout),
166+
):
167+
interceptor = UiPathRuntimeLogsInterceptor(
168+
job_id="job-123", dir=str(tmp_path), file="test.log"
169+
)
170+
171+
assert not hasattr(interceptor, "utf8_stdout")

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)