https://github.com/kastiglione created https://github.com/llvm/llvm-project/pull/201751
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. >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] [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) _______________________________________________ lldb-commits mailing list [email protected] https://lists.llvm.org/cgi-bin/mailman/listinfo/lldb-commits
