Skip to content

Commit 37090f5

Browse files
committed
fix: support subprocess stderr inheritance in Textual UI
1 parent ec7d061 commit 37090f5

3 files changed

Lines changed: 83 additions & 3 deletions

File tree

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"
3-
version = "2.1.69"
3+
version = "2.1.70"
44
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.10"

src/uipath/_cli/_dev/_terminal/__init__.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
from ._models._messages import LogMessage, TraceMessage
3838
from ._utils._chat import RunContextChatHandler, build_user_message_event
3939
from ._utils._exporter import RunContextExporter
40-
from ._utils._logger import RunContextLogHandler
40+
from ._utils._logger import RunContextLogHandler, patch_textual_stderr
4141

4242

4343
class UiPathDevTerminal(App[Any]):
@@ -61,6 +61,8 @@ def __init__(
6161
runtime_factory: UiPathRuntimeFactory[Any, Any],
6262
**kwargs,
6363
):
64+
self._stderr_write_fd: int = patch_textual_stderr(self._add_subprocess_log)
65+
6466
super().__init__(**kwargs)
6567

6668
self.initial_entrypoint: str = "main.py"
@@ -357,3 +359,15 @@ def _add_error_log(self, run: ExecutionRun):
357359
)
358360
log_msg = LogMessage(run.id, "ERROR", tb, timestamp)
359361
self._handle_log_message(log_msg)
362+
363+
def _add_subprocess_log(self, level: str, message: str) -> None:
364+
"""Handle a stderr line coming from subprocesses."""
365+
366+
def add_log() -> None:
367+
details_panel = self.query_one("#details-panel", RunDetailsPanel)
368+
run = getattr(details_panel, "current_run", None)
369+
if run:
370+
log_msg = LogMessage(run.id, level, message, datetime.now())
371+
self._handle_log_message(log_msg)
372+
373+
self.call_from_thread(add_log)

src/uipath/_cli/_dev/_terminal/_utils/_logger.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
from __future__ import annotations
2+
13
import logging
4+
import os
5+
import re
6+
import threading
27
from datetime import datetime
3-
from typing import Callable
8+
from typing import Callable, Pattern
49

510
from .._models._messages import LogMessage
611

@@ -30,3 +35,64 @@ def emit(self, record: logging.LogRecord):
3035
except Exception:
3136
# Don't let logging errors crash the app
3237
pass
38+
39+
40+
# A dispatcher is a callable that accepts (level, message) pairs
41+
DispatchLog = Callable[[str, str], None]
42+
43+
LEVEL_PATTERNS: list[tuple[str, Pattern[str]]] = [
44+
("DEBUG", re.compile(r"^(DEBUG)[:\s-]+", re.I)),
45+
("INFO", re.compile(r"^(INFO)[:\s-]+", re.I)),
46+
("WARN", re.compile(r"^(WARNING|WARN)[:\s-]+", re.I)),
47+
("ERROR", re.compile(r"^(ERROR|ERRO)[:\s-]+", re.I)),
48+
]
49+
50+
51+
def patch_textual_stderr(dispatch_log: DispatchLog) -> int:
52+
"""Redirect subprocess stderr into a provided dispatcher.
53+
54+
Args:
55+
dispatch_log: Callable invoked with (level, message) for each stderr line.
56+
This will be called from a background thread, so the caller
57+
should use `App.call_from_thread` or equivalent.
58+
59+
Returns:
60+
int: The write file descriptor for stderr (pass to subprocesses).
61+
"""
62+
from textual.app import _PrintCapture
63+
64+
read_fd, write_fd = os.pipe()
65+
66+
# Patch fileno() so subprocesses can write to our pipe
67+
_PrintCapture.fileno = lambda self: write_fd # type: ignore[method-assign]
68+
69+
def read_stderr_pipe() -> None:
70+
with os.fdopen(read_fd, "r", buffering=1) as pipe_reader:
71+
try:
72+
for raw in pipe_reader:
73+
text = raw.rstrip()
74+
level: str = "ERROR"
75+
message: str = text
76+
77+
# Try to parse a known level prefix
78+
for lvl, pattern in LEVEL_PATTERNS:
79+
m = pattern.match(text)
80+
if m:
81+
level = lvl
82+
message = text[m.end() :]
83+
break
84+
85+
dispatch_log(level, message)
86+
87+
except Exception:
88+
# Never raise from thread
89+
pass
90+
91+
thread = threading.Thread(
92+
target=read_stderr_pipe,
93+
daemon=True,
94+
name="stderr-reader",
95+
)
96+
thread.start()
97+
98+
return write_fd

0 commit comments

Comments
 (0)