From patchwork Fri May 1 10:59:54 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Simon Glass X-Patchwork-Id: 2245 Return-Path: X-Original-To: u-boot-concept@u-boot.org Delivered-To: u-boot-concept@u-boot.org DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1777633263; bh=7Anzd6WIEDJIoqFusXeFYrFfhwUbJFL9tiMcqWHYA/s=; h=From:To:Date:In-Reply-To:References:CC:Subject:List-Id: List-Archive:List-Help:List-Owner:List-Post:List-Subscribe: List-Unsubscribe:From; b=FkE5AdWP/T1XuHOfaC+DuqJRA4UFUjEvGxfVzqVSAjAFXNeu641JqOluq8QLA6XyL s6mYJ7YFXpX8ROd6KUXSMfYDDb7YXRgjhXbpSKdfQvm7RJHpFa0FNPNKptfmxrlcIc 1Kioig50Db5gUzun86g8AfYc8u5DyelvvVt2ko9c= Received: from localhost (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id 6086E6A833 for ; Fri, 1 May 2026 05:01:03 -0600 (MDT) X-Virus-Scanned: Debian amavis at Received: from mail.u-boot.org ([127.0.0.1]) by localhost (mail.u-boot.org [127.0.0.1]) (amavis, port 10024) with ESMTP id doPhCKGfAgxK for ; Fri, 1 May 2026 05:01:03 -0600 (MDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1777633262; bh=7Anzd6WIEDJIoqFusXeFYrFfhwUbJFL9tiMcqWHYA/s=; h=From:To:Date:In-Reply-To:References:CC:Subject:List-Id: List-Archive:List-Help:List-Owner:List-Post:List-Subscribe: List-Unsubscribe:From; b=XBIjwOSEn6mJ1kh3RUBHG8/+qRcu+Xwjh9qOKpyhfny9WwNVeY9vVkPE9t2DCE37m RJJ1IDfH26+LtwXYE1pKJDKXKImFpkXf3fdK3bV2IfHhr5zyCSlCFaWnEHR/9z0iWk bx2BcM9bIolYP4TfwilSFi8vN3I7QPRcFkPzS6YA= Received: from mail.u-boot.org (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id 5895D6A834 for ; Fri, 1 May 2026 05:01:02 -0600 (MDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1777633259; bh=CbC1Jc4opFRvZ4tVhxFgM8xow0fl4HUEW0IZBnqJGmI=; h=From:To:Cc:Subject:Date:In-Reply-To:References:From; b=pR15Mlv2jISBViSd6+58+atJwxw555mtXGVwi/Y5dnyVFA5f9On072IhUs3tiPWCj Wh1apmLsXf6mmMMTBhZDAIMBwfZGsg2yrNMxA86zd8hXWgQQ3+WuiKoWetqL8l6EzM E3Z69JnuZCrJo1TspL4uXIinGiErS44/zNt0zIM0= Received: from localhost (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id C66D16A82E; Fri, 1 May 2026 05:00:59 -0600 (MDT) X-Virus-Scanned: Debian amavis at Received: from mail.u-boot.org ([127.0.0.1]) by localhost (mail.u-boot.org [127.0.0.1]) (amavis, port 10026) with ESMTP id fEYW8jHGEHM3; Fri, 1 May 2026 05:00:59 -0600 (MDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1777633259; bh=F8mUTY5We5+ukou59JuvVVG4tQZx3GW3Ea5jj9FwvUg=; h=From:To:Cc:Subject:Date:In-Reply-To:References:From; b=n7F1hPQSOs5RYrxUg3eXW/32pAQDqqAir7w+t9JHel79T15Lf2sPpd89drfWfwixS n1yXy4n67xwkjhx7zYXUoEXpfLqXtPY3nZ1nt8RrZaSBA3VSLLYy7ue4jfSFCfl4jJ Nli9mlO+bdGB0/GvYjH/MTQM1ZvtFN4o+1g1LrVM= Received: from u-boot.org (unknown [174.51.25.52]) by mail.u-boot.org (Postfix) with ESMTPSA id 330A96A7AF; Fri, 1 May 2026 05:00:59 -0600 (MDT) From: Simon Glass To: U-Boot Concept Date: Fri, 1 May 2026 04:59:54 -0600 Message-ID: <20260501110040.1874719-3-sjg@u-boot.org> X-Mailer: git-send-email 2.43.0 In-Reply-To: <20260501110040.1874719-1-sjg@u-boot.org> References: <20260501110040.1874719-1-sjg@u-boot.org> MIME-Version: 1.0 Message-ID-Hash: XR3VNIPOBWWRWDGIDZSKOH65H7UTE7TU X-Message-ID-Hash: XR3VNIPOBWWRWDGIDZSKOH65H7UTE7TU X-MailFrom: sjg@u-boot.org X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header CC: Simon Glass X-Mailman-Version: 3.3.10 Precedence: list Subject: [Concept] [PATCH 02/29] u_boot_pylib: Add run_interactive() for PTY commands List-Id: Discussion and patches related to U-Boot Concept Archived-At: List-Archive: List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: From: Simon Glass Add a helper that runs a command with its stdout and stderr connected to a PTY (so interactive programs flush before prompting) while the child inherits stdin from the parent process. A reader thread copies PTY output to fd 1 and accumulates it for the caller. This is similar to cros_subprocess with PIPE_PTY, but cros_subprocess always intercepts stdin which prevents the user from responding to prompts directly. Letting Popen inherit stdin from fd 0 (rather than referencing sys.stdin directly) keeps the helper usable inside test runners that close sys.stdin in forked workers. Add unit tests covering stdout capture, stderr-through-PTY capture, the silent-failure return value, and the cwd argument. Register the new TestRunInteractive class with both 'patman test' and the u_boot_pylib test runner so the tests are picked up by either entry point. Signed-off-by: Simon Glass --- tools/patman/__main__.py | 3 +- tools/u_boot_pylib/__main__.py | 4 ++- tools/u_boot_pylib/command.py | 51 ++++++++++++++++++++++++++ tools/u_boot_pylib/test_command.py | 58 ++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 tools/u_boot_pylib/test_command.py diff --git a/tools/patman/__main__.py b/tools/patman/__main__.py index 5dce54bf676..44219b3eb9a 100755 --- a/tools/patman/__main__.py +++ b/tools/patman/__main__.py @@ -40,13 +40,14 @@ def run_patman(): from patman import func_test from patman import test_checkpatch from patman import test_cseries + from u_boot_pylib import test_command to_run = args.testname if args.testname not in [None, 'test'] else None result = test_util.run_test_suites( 'patman', False, args.verbose, args.no_capture, args.test_preserve_dirs, None, to_run, None, [test_checkpatch.TestPatch, func_test.TestFunctional, 'settings', - test_cseries.TestCseries]) + test_cseries.TestCseries, test_command.TestRunInteractive]) sys.exit(0 if result.wasSuccessful() else 1) # Process commits, produce patches files, check them, email them diff --git a/tools/u_boot_pylib/__main__.py b/tools/u_boot_pylib/__main__.py index 5687f9b51a5..ee03f51bb9e 100755 --- a/tools/u_boot_pylib/__main__.py +++ b/tools/u_boot_pylib/__main__.py @@ -29,13 +29,15 @@ def run_tests(): args = parser.parse_args() from u_boot_pylib import test_claude + from u_boot_pylib import test_command to_run = args.testname if args.testname not in [None, 'test'] else None result = test_util.run_test_suites( 'u_boot_pylib', False, args.verbose, False, False, None, to_run, None, ['u_boot_pylib.terminal', 'u_boot_pylib.gitutil', - cros_subprocess.TestSubprocess, test_claude.TestClaude]) + cros_subprocess.TestSubprocess, test_claude.TestClaude, + test_command.TestRunInteractive]) sys.exit(0 if result.wasSuccessful() else 1) diff --git a/tools/u_boot_pylib/command.py b/tools/u_boot_pylib/command.py index c44bed6acc0..f54c381e589 100644 --- a/tools/u_boot_pylib/command.py +++ b/tools/u_boot_pylib/command.py @@ -5,7 +5,10 @@ Shell command ease-ups for Python Copyright (c) 2011 The Chromium OS Authors. """ +import os +import pty import subprocess +import threading from u_boot_pylib import cros_subprocess @@ -160,6 +163,54 @@ def run_pipe(pipe_list, infile=None, outfile=None, capture=False, return result +def run_interactive(cmd, cwd=None): + """Run an interactive command with a PTY for correct output ordering + + Similar to cros_subprocess.Popen with PIPE_PTY, but the child's stdin is + inherited from the parent process so the user can respond to prompts + directly. cros_subprocess always intercepts stdin which prevents interactive + use. + + The child's stdout and stderr go through a PTY so interactive programs (like + git send-email) flush before prompting. A reader thread copies PTY output to + fd 1 and accumulates it for the caller. + + Args: + cmd (list of str): Command to run + cwd (str or None): Working directory + + Returns: + str: All output produced by the command + """ + parent_fd, child_fd = pty.openpty() + captured = [] + + def reader(): + """Drain the PTY: copy each chunk to fd 1 and remember it""" + try: + while True: + data = os.read(parent_fd, 4096) + if not data: + break + try: + os.write(1, data) + except OSError: + pass + captured.append(data) + except OSError: + pass + + thr = threading.Thread(target=reader, daemon=True) + thr.start() + + proc = subprocess.Popen(cmd, cwd=cwd, stdout=child_fd, stderr=child_fd) + os.close(child_fd) + proc.wait() + thr.join(timeout=2) + os.close(parent_fd) + return b''.join(captured).decode('utf-8', errors='replace') + + def output(*cmd, **kwargs): """Run a command and return its output diff --git a/tools/u_boot_pylib/test_command.py b/tools/u_boot_pylib/test_command.py new file mode 100644 index 00000000000..ab3cec0fa89 --- /dev/null +++ b/tools/u_boot_pylib/test_command.py @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: GPL-2.0+ +# Copyright 2026 Simon Glass + +"""Tests for the u_boot_pylib.command module""" + +import contextlib +import os +import unittest + +from u_boot_pylib import command + + +@contextlib.contextmanager +def _silence_fd1(): + """Redirect raw fd 1 to /dev/null so PTY echo does not leak""" + saved = os.dup(1) + devnull = os.open(os.devnull, os.O_WRONLY) + try: + os.dup2(devnull, 1) + yield + finally: + os.dup2(saved, 1) + os.close(saved) + os.close(devnull) + + +class TestRunInteractive(unittest.TestCase): + """Tests for command.run_interactive()""" + + def test_captures_stdout(self): + """run_interactive() returns text written to stdout""" + with _silence_fd1(): + out = command.run_interactive(['printf', 'hello']) + self.assertIn('hello', out) + + def test_captures_stderr(self): + """run_interactive() also captures stderr through the PTY""" + with _silence_fd1(): + out = command.run_interactive( + ['sh', '-c', 'printf out; printf err >&2']) + self.assertIn('out', out) + self.assertIn('err', out) + + def test_silent_failing_command(self): + """run_interactive() returns empty for a silent failing command""" + with _silence_fd1(): + out = command.run_interactive(['false']) + self.assertEqual('', out) + + def test_cwd(self): + """run_interactive() honours the cwd argument""" + with _silence_fd1(): + out = command.run_interactive(['pwd'], cwd='/tmp') + self.assertIn('/tmp', out) + + +if __name__ == '__main__': + unittest.main()