From 1d31b70f20806363818f827f281b7d8b733132b0 Mon Sep 17 00:00:00 2001 From: Cristian Pufu Date: Sat, 6 Dec 2025 14:57:11 +0200 Subject: [PATCH] fix: stdout redirect to file --- pyproject.toml | 2 +- src/uipath/runtime/logging/_interceptor.py | 29 ++++++++++-- src/uipath/runtime/logging/_writers.py | 52 +++++++++++++++++----- uv.lock | 2 +- 4 files changed, 68 insertions(+), 17 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dc6fd9a..937ce53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.2.3" +version = "0.2.4" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/runtime/logging/_interceptor.py b/src/uipath/runtime/logging/_interceptor.py index 397a2bc..e520122 100644 --- a/src/uipath/runtime/logging/_interceptor.py +++ b/src/uipath/runtime/logging/_interceptor.py @@ -1,5 +1,6 @@ """Main logging interceptor for execution context.""" +import io import logging import os import sys @@ -41,6 +42,7 @@ def __init__( min_level = min_level or "INFO" self.job_id = job_id self.execution_id = execution_id + self._owns_handler: bool = log_handler is None # Convert to numeric level for consistent comparison self.numeric_min_level = getattr(logging, min_level.upper(), logging.INFO) @@ -67,8 +69,25 @@ def __init__( else: # Create either file handler (runtime) or stdout handler (debug) if not job_id: - # Use stdout handler when not running as a job or eval - self.log_handler = logging.StreamHandler(sys.stdout) + # Only wrap if stdout is using a problematic encoding (like cp1252 on Windows) + if ( + hasattr(sys.stdout, "encoding") + and hasattr(sys.stdout, "buffer") + and sys.stdout.encoding + and sys.stdout.encoding.lower() not in ("utf-8", "utf8") + ): + # Wrap stdout with UTF-8 encoding for the handler + self.utf8_stdout = io.TextIOWrapper( + sys.stdout.buffer, + encoding="utf-8", + errors="replace", + line_buffering=True, + ) + self.log_handler = logging.StreamHandler(self.utf8_stdout) + else: + # stdout already has good encoding, use it directly + self.log_handler = logging.StreamHandler(sys.stdout) + formatter = logging.Formatter("%(message)s") self.log_handler.setFormatter(formatter) else: @@ -214,7 +233,11 @@ def teardown(self) -> None: if handler not in self.root_logger.handlers: self.root_logger.addHandler(handler) - self.log_handler.close() + if self._owns_handler: + self.log_handler.close() + + if hasattr(self, "utf8_stdout"): + self.utf8_stdout.close() # Only restore streams if we redirected them if self.original_stdout and self.original_stderr: diff --git a/src/uipath/runtime/logging/_writers.py b/src/uipath/runtime/logging/_writers.py index fdd9108..f33732b 100644 --- a/src/uipath/runtime/logging/_writers.py +++ b/src/uipath/runtime/logging/_writers.py @@ -20,23 +20,48 @@ def __init__( self.min_level = min_level self.buffer = "" self.sys_file = sys_file + self._in_logging = False # Recursion guard def write(self, message: str) -> None: """Write message to the logger, buffering until newline.""" - self.buffer += message - while "\n" in self.buffer: - line, self.buffer = self.buffer.split("\n", 1) - # Only log if the message is not empty and the level is sufficient - if line and self.level >= self.min_level: - # The context variable is automatically available here - self.logger._log(self.level, line, ()) + # Prevent infinite recursion when logging.handleError writes to stderr + if self._in_logging: + if self.sys_file: + try: + self.sys_file.write(message) + except (OSError, IOError): + pass # Fail silently if we can't write + return + + try: + self._in_logging = True + self.buffer += message + while "\n" in self.buffer: + line, self.buffer = self.buffer.split("\n", 1) + # Only log if the message is not empty and the level is sufficient + if line and self.level >= self.min_level: + self.logger._log(self.level, line, ()) + finally: + self._in_logging = False def flush(self) -> None: """Flush any remaining buffered messages to the logger.""" - # Log any remaining content in the buffer on flush - if self.buffer and self.level >= self.min_level: - self.logger._log(self.level, self.buffer, ()) - self.buffer = "" + if self._in_logging: + if self.sys_file: + try: + self.sys_file.flush() + except (OSError, IOError): + pass # Fail silently if we can't flush + return + + try: + self._in_logging = True + # Log any remaining content in the buffer on flush + if self.buffer and self.level >= self.min_level: + self.logger._log(self.level, self.buffer, ()) + self.buffer = "" + finally: + self._in_logging = False def fileno(self) -> int: """Get the file descriptor of the original sys.stdout/sys.stderr.""" @@ -47,7 +72,10 @@ def fileno(self) -> int: def isatty(self) -> bool: """Check if the original sys.stdout/sys.stderr is a TTY.""" - return hasattr(self.sys_file, "isatty") and self.sys_file.isatty() + try: + return hasattr(self.sys_file, "isatty") and self.sys_file.isatty() + except (AttributeError, OSError, ValueError): + return False def writable(self) -> bool: """Check if the original sys.stdout/sys.stderr is writable.""" diff --git a/uv.lock b/uv.lock index 85b1a23..c8d6f0d 100644 --- a/uv.lock +++ b/uv.lock @@ -1005,7 +1005,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.2.3" +version = "0.2.4" source = { editable = "." } dependencies = [ { name = "uipath-core" },