https://github.com/kastiglione updated https://github.com/llvm/llvm-project/pull/201751
>From f7a176229ea9a219a67aa455736faa551f9ba718 Mon Sep 17 00:00:00 2001 From: Dave Lee <[email protected]> Date: Thu, 4 Jun 2026 22:24:46 -0700 Subject: [PATCH 1/4] [lldb] Add `pipe` command for piping through shell commands Add a new Python utility (`lldb.utils.pipe`) that lets users pipe the output of lldb commands through shell pipelines or redirect to files. The command distinguishs lldb commands from shell commands, both work transparently: (lldb) pipe bt all | grep main (lldb) pipe settings list > /tmp/settings.txt (lldb) pipe /bin/ls | head -5 Pager detection (`less`, `more`, `bat`, `$PAGER`) is used to bypass output capture, ensuring that interactive pagers can access the tty. --- lldb/bindings/python/CMakeLists.txt | 1 + lldb/examples/python/pipe.py | 144 ++++++++++++++++++ .../commands/command/pipe/TestPipeCommand.py | 65 ++++++++ 3 files changed, 210 insertions(+) create mode 100644 lldb/examples/python/pipe.py create mode 100644 lldb/test/API/commands/command/pipe/TestPipeCommand.py diff --git a/lldb/bindings/python/CMakeLists.txt b/lldb/bindings/python/CMakeLists.txt index e4f978ed80bc3..dacfd49054ada 100644 --- a/lldb/bindings/python/CMakeLists.txt +++ b/lldb/bindings/python/CMakeLists.txt @@ -102,6 +102,7 @@ function(finish_swig_python swig_target lldb_python_bindings_dir lldb_python_tar ${lldb_python_target_dir} "utils" FILES "${LLDB_SOURCE_DIR}/examples/python/in_call_stack.py" + "${LLDB_SOURCE_DIR}/examples/python/pipe.py" "${LLDB_SOURCE_DIR}/examples/python/symbolication.py" ) diff --git a/lldb/examples/python/pipe.py b/lldb/examples/python/pipe.py new file mode 100644 index 0000000000000..183df8d11ff5a --- /dev/null +++ b/lldb/examples/python/pipe.py @@ -0,0 +1,144 @@ +r""" +Pipe or redirect LLDB command output through shell commands. + +Usage: + (lldb) pipe <lldb-command> | <shell-pipeline> + (lldb) pipe <lldb-command> > <file> +Examples: + (lldb) pipe image list | wc -l + (lldb) pipe settings list | grep color + (lldb) pipe bt all | grep Foundation + (lldb) pipe bt all > /tmp/stack.txt + (lldb) pipe bt all | tee /tmp/stack.txt + (lldb) pipe frame variable *this | pbcopy + (lldb) pipe breakpoint list | grep 'where = .*resolved' + (lldb) pipe register read | sort + +Import: + (lldb) command script import lldb.utils.pipe +""" + +import os +import shlex +import subprocess +from typing import Iterable, Iterator, Optional, Tuple + +import lldb + + +# Equivalent to itertools.pairwise, available in Python 3.10+. +def _pairwise(iterable: Iterable) -> Iterator[Tuple]: + """Yield consecutive overlapping pairs: _pairwise(ABCD) -> AB BC CD.""" + iterator = iter(iterable) + a = next(iterator, None) + for b in iterator: + yield a, b + a = b + + +def _tokenize(cmdstr: str) -> shlex.shlex: + """Splits on whitespace and treats | and > as standalone punctuation, even + without surrounding spaces.""" + + lex = shlex.shlex(cmdstr, posix=True, punctuation_chars="|>") + lex.whitespace_split = True + return lex + + +def _split_cmd(cmdstr: str) -> Tuple[str, Optional[str]]: + """Split cmdstr at the first unquoted shell operator (| or >).""" + lex = _tokenize(cmdstr) + for token in lex: + if token in ("|", ">"): + # Search up to where shlex has consumed. + split = cmdstr.rindex(token, 0, lex.instream.tell()) + first_cmd = cmdstr[:split].rstrip() + shell_suffix = cmdstr[split:] + return first_cmd, shell_suffix + return cmdstr, None + + +def _pager_names() -> set[str]: + """Return known pager command names, including $PAGER if set.""" + pagers = {"less", "more", "most", "bat"} + if pager := os.environ.get("PAGER"): + # shlex.split handles flags, e.g. "less -R" -> "less". + pager = shlex.split(pager)[0] + pagers.add(os.path.basename(pager)) + return pagers + + +_PAGERS = _pager_names() + + +def _ends_with_pager(shell_cmd: str) -> bool: + """Check if the last command in a shell pipeline is a pager.""" + lex = _tokenize(shell_cmd) + for tok1, tok2 in reversed(list(_pairwise(lex))): + if tok1 == "|": + return os.path.basename(tok2) in _PAGERS + return False + + +def _is_lldb_command(interp: lldb.SBCommandInterpreter, cmdstr: str) -> bool: + result = lldb.SBCommandReturnObject() + interp.ResolveCommand(cmdstr, result) + return result.Succeeded() + + [email protected]() +def pipe( + debugger: lldb.SBDebugger, + cmdstr: str, + exe_ctx: lldb.SBExecutionContext, + result: lldb.SBCommandReturnObject, + _, +): + """Pipe or redirect LLDB command output through shell commands.""" + interp = debugger.GetCommandInterpreter() + + first_cmd, shell_suffix = _split_cmd(cmdstr) + + if not first_cmd and shell_suffix: + result.SetError("no command before shell operator") + return + + if _is_lldb_command(interp, first_cmd): + if shell_suffix is None: + # Run a single lldb command, shell command to pipe to. + interp.HandleCommand(first_cmd, exe_ctx, result) + return + + cmd_result = lldb.SBCommandReturnObject() + interp.HandleCommand(first_cmd, exe_ctx, cmd_result) + + if cmd_result.GetError(): + result.SetError(cmd_result.GetError()) + if not cmd_result.Succeeded(): + if not cmd_result.GetError(): + result.SetError(f"command failed: {first_cmd}") + return + + output = cmd_result.GetOutput() + # `cat` works with both | and > operators. + shell_cmd = f"cat {shell_suffix}" + else: + # No lldb command to run, only shell. + output = None + shell_cmd = cmdstr + + try: + if _ends_with_pager(shell_cmd): + proc = subprocess.Popen(shell_cmd, shell=True, stdin=subprocess.PIPE) + proc.communicate(output.encode() if output else None) + else: + proc = subprocess.run( + shell_cmd, shell=True, input=output, capture_output=True, text=True + ) + if proc.stdout: + result.PutCString(proc.stdout) + if proc.stderr: + result.SetError(proc.stderr) + except OSError as e: + result.SetError(f"failed to run shell command: {e}") + return diff --git a/lldb/test/API/commands/command/pipe/TestPipeCommand.py b/lldb/test/API/commands/command/pipe/TestPipeCommand.py new file mode 100644 index 0000000000000..dffdee4414700 --- /dev/null +++ b/lldb/test/API/commands/command/pipe/TestPipeCommand.py @@ -0,0 +1,65 @@ +""" +Test the pipe command (lldb.utils.pipe). +""" + +import os +import tempfile + +import lldb +from lldbsuite.test.lldbtest import * + + +class TestCase(TestBase): + NO_DEBUG_INFO_TESTCASE = True + + def setUp(self): + TestBase.setUp(self) + self.runCmd("command script import lldb.utils.pipe") + + def test_pipe_to_grep(self): + """Test piping an LLDB command through grep.""" + self.expect("pipe help help | grep Syntax", substrs=["Syntax"]) + + def test_lldb_command_no_pipe(self): + """Test running a plain LLDB command (passthrough).""" + self.expect("pipe help help", substrs=["Syntax"]) + + def test_pipe_chain(self): + """Test piping through multiple shell commands.""" + self.expect("pipe help help | tr s-z S-Z | grep -i syntax", substrs=["SYnTaX"]) + + def test_redirect_stdout(self): + """Test redirecting LLDB command output to a file.""" + with tempfile.NamedTemporaryFile(mode="r", delete=False) as f: + path = f.name + try: + self.runCmd(f"pipe help help > {path}") + with open(path) as f: + contents = f.read() + self.assertIn("Syntax", contents) + finally: + os.unlink(path) + + def test_shell_command_no_pipe(self): + """Test running a plain shell command (not an LLDB command).""" + self.expect("pipe echo hello", substrs=["hello"]) + + def test_shell_command_with_pipe(self): + """Test running a shell command piped to another shell command.""" + self.expect("pipe echo hello world | tr a-z A-Z", substrs=["HELLO WORLD"]) + + def test_quoted_pipe_not_split(self): + """Test that a pipe character inside quotes is not treated as a split.""" + self.expect("pipe echo 'abc|def' | tr a-c A-C", substrs=["ABC|def"]) + + def test_pipe_without_spaces(self): + """Test that pipe works without spaces around the | operator.""" + self.expect("pipe help help|grep Syntax", substrs=["Syntax"]) + + def test_error_no_command_before_pipe(self): + """Test error when nothing precedes the pipe operator.""" + self.expect("pipe | grep foo", error=True) + + def test_failed_lldb_command(self): + """Test that a non-lldb non-shell command reports an error.""" + self.expect("pipe not_a_real_lldb_command_xyz | wc -l", error=True) >From b99ac609668725367ac7819ee88c39612b378a55 Mon Sep 17 00:00:00 2001 From: Dave Lee <[email protected]> Date: Fri, 5 Jun 2026 10:25:53 -0700 Subject: [PATCH 2/4] Add >> support; Disable some tests for windows --- lldb/examples/python/pipe.py | 23 ++++++++++--------- .../commands/command/pipe/TestPipeCommand.py | 21 ++++++++++++++++- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/lldb/examples/python/pipe.py b/lldb/examples/python/pipe.py index 183df8d11ff5a..61f89485767dc 100644 --- a/lldb/examples/python/pipe.py +++ b/lldb/examples/python/pipe.py @@ -4,6 +4,7 @@ Usage: (lldb) pipe <lldb-command> | <shell-pipeline> (lldb) pipe <lldb-command> > <file> + (lldb) pipe <lldb-command> >> <file> Examples: (lldb) pipe image list | wc -l (lldb) pipe settings list | grep color @@ -46,12 +47,12 @@ def _tokenize(cmdstr: str) -> shlex.shlex: def _split_cmd(cmdstr: str) -> Tuple[str, Optional[str]]: - """Split cmdstr at the first unquoted shell operator (| or >).""" - lex = _tokenize(cmdstr) - for token in lex: - if token in ("|", ">"): + """Split cmdstr at the first unquoted shell operator (|, >, or >>).""" + tokens = _tokenize(cmdstr) + for token in tokens: + if token in ("|", ">", ">>"): # Search up to where shlex has consumed. - split = cmdstr.rindex(token, 0, lex.instream.tell()) + split = cmdstr.rindex(token, 0, tokens.instream.tell()) first_cmd = cmdstr[:split].rstrip() shell_suffix = cmdstr[split:] return first_cmd, shell_suffix @@ -73,10 +74,10 @@ def _pager_names() -> set[str]: def _ends_with_pager(shell_cmd: str) -> bool: """Check if the last command in a shell pipeline is a pager.""" - lex = _tokenize(shell_cmd) - for tok1, tok2 in reversed(list(_pairwise(lex))): - if tok1 == "|": - return os.path.basename(tok2) in _PAGERS + tokens = _tokenize(shell_cmd) + for token1, token2 in reversed(list(_pairwise(tokens))): + if token1 == "|": + return os.path.basename(token2) in _PAGERS return False @@ -120,7 +121,8 @@ def pipe( return output = cmd_result.GetOutput() - # `cat` works with both | and > operators. + # Prefixing shell_suffix with `cat` is a single way to make both `| ...` + # and `> ...` work as a subprocess command. shell_cmd = f"cat {shell_suffix}" else: # No lldb command to run, only shell. @@ -141,4 +143,3 @@ def pipe( result.SetError(proc.stderr) except OSError as e: result.SetError(f"failed to run shell command: {e}") - return diff --git a/lldb/test/API/commands/command/pipe/TestPipeCommand.py b/lldb/test/API/commands/command/pipe/TestPipeCommand.py index dffdee4414700..e4cb4a1cc1fff 100644 --- a/lldb/test/API/commands/command/pipe/TestPipeCommand.py +++ b/lldb/test/API/commands/command/pipe/TestPipeCommand.py @@ -7,7 +7,7 @@ import lldb from lldbsuite.test.lldbtest import * - +from lldbsuite.test.decorators import * class TestCase(TestBase): NO_DEBUG_INFO_TESTCASE = True @@ -16,6 +16,7 @@ def setUp(self): TestBase.setUp(self) self.runCmd("command script import lldb.utils.pipe") + @skipIfWindows def test_pipe_to_grep(self): """Test piping an LLDB command through grep.""" self.expect("pipe help help | grep Syntax", substrs=["Syntax"]) @@ -24,6 +25,7 @@ def test_lldb_command_no_pipe(self): """Test running a plain LLDB command (passthrough).""" self.expect("pipe help help", substrs=["Syntax"]) + @skipIfWindows def test_pipe_chain(self): """Test piping through multiple shell commands.""" self.expect("pipe help help | tr s-z S-Z | grep -i syntax", substrs=["SYnTaX"]) @@ -40,18 +42,35 @@ def test_redirect_stdout(self): finally: os.unlink(path) + def test_append_stdout(self): + """Test appending LLDB command output to a file.""" + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + f.write("first\n") + path = f.name + try: + self.runCmd(f"pipe help help >> {path}") + with open(path) as f: + contents = f.read() + self.assertTrue(contents.startswith("first\n")) + self.assertIn("Syntax", contents) + finally: + os.unlink(path) + def test_shell_command_no_pipe(self): """Test running a plain shell command (not an LLDB command).""" self.expect("pipe echo hello", substrs=["hello"]) + @skipIfWindows def test_shell_command_with_pipe(self): """Test running a shell command piped to another shell command.""" self.expect("pipe echo hello world | tr a-z A-Z", substrs=["HELLO WORLD"]) + @skipIfWindows def test_quoted_pipe_not_split(self): """Test that a pipe character inside quotes is not treated as a split.""" self.expect("pipe echo 'abc|def' | tr a-c A-C", substrs=["ABC|def"]) + @skipIfWindows def test_pipe_without_spaces(self): """Test that pipe works without spaces around the | operator.""" self.expect("pipe help help|grep Syntax", substrs=["Syntax"]) >From fcb5cc58a0e4534a9bf8c63d97ead74755113679 Mon Sep 17 00:00:00 2001 From: Dave Lee <[email protected]> Date: Fri, 5 Jun 2026 11:53:17 -0700 Subject: [PATCH 3/4] reformat --- lldb/test/API/commands/command/pipe/TestPipeCommand.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lldb/test/API/commands/command/pipe/TestPipeCommand.py b/lldb/test/API/commands/command/pipe/TestPipeCommand.py index e4cb4a1cc1fff..0ff6e6649ec05 100644 --- a/lldb/test/API/commands/command/pipe/TestPipeCommand.py +++ b/lldb/test/API/commands/command/pipe/TestPipeCommand.py @@ -9,6 +9,7 @@ from lldbsuite.test.lldbtest import * from lldbsuite.test.decorators import * + class TestCase(TestBase): NO_DEBUG_INFO_TESTCASE = True >From e3467648a19b343fad842ca90350c0baecc00121 Mon Sep 17 00:00:00 2001 From: Dave Lee <[email protected]> Date: Fri, 5 Jun 2026 11:58:18 -0700 Subject: [PATCH 4/4] mark full test @skipIfWindows --- lldb/test/API/commands/command/pipe/TestPipeCommand.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lldb/test/API/commands/command/pipe/TestPipeCommand.py b/lldb/test/API/commands/command/pipe/TestPipeCommand.py index 0ff6e6649ec05..96817e02927b1 100644 --- a/lldb/test/API/commands/command/pipe/TestPipeCommand.py +++ b/lldb/test/API/commands/command/pipe/TestPipeCommand.py @@ -10,6 +10,7 @@ from lldbsuite.test.decorators import * +@skipIfWindows class TestCase(TestBase): NO_DEBUG_INFO_TESTCASE = True @@ -17,7 +18,6 @@ def setUp(self): TestBase.setUp(self) self.runCmd("command script import lldb.utils.pipe") - @skipIfWindows def test_pipe_to_grep(self): """Test piping an LLDB command through grep.""" self.expect("pipe help help | grep Syntax", substrs=["Syntax"]) @@ -26,7 +26,6 @@ def test_lldb_command_no_pipe(self): """Test running a plain LLDB command (passthrough).""" self.expect("pipe help help", substrs=["Syntax"]) - @skipIfWindows def test_pipe_chain(self): """Test piping through multiple shell commands.""" self.expect("pipe help help | tr s-z S-Z | grep -i syntax", substrs=["SYnTaX"]) @@ -61,17 +60,14 @@ def test_shell_command_no_pipe(self): """Test running a plain shell command (not an LLDB command).""" self.expect("pipe echo hello", substrs=["hello"]) - @skipIfWindows def test_shell_command_with_pipe(self): """Test running a shell command piped to another shell command.""" self.expect("pipe echo hello world | tr a-z A-Z", substrs=["HELLO WORLD"]) - @skipIfWindows def test_quoted_pipe_not_split(self): """Test that a pipe character inside quotes is not treated as a split.""" self.expect("pipe echo 'abc|def' | tr a-c A-C", substrs=["ABC|def"]) - @skipIfWindows def test_pipe_without_spaces(self): """Test that pipe works without spaces around the | operator.""" self.expect("pipe help help|grep Syntax", substrs=["Syntax"]) _______________________________________________ lldb-commits mailing list [email protected] https://lists.llvm.org/cgi-bin/mailman/listinfo/lldb-commits
