Add interactive filter UI and finish the Python 3 port

Logs now render in a full-screen view above a bottom prompt line when both
stdin and stdout are a terminal; typing live-filters the scrollback to lines
containing every typed word, case-insensitively. --plain restores the
previous streaming output, and piped usage is unaffected either way.

Also finishes the Python 3 port that #177 and #173 started:

- print(linebuf.encode('utf-8')) printed the bytes repr of every line instead
  of the line itself
- named_processes was still a lazily-exhausted map() object after #177
  wrapped catchall_package in list()
- piping adb logcat into pidcat crashed, since FakeStdinProcess read
  sys.stdin (str) where the rest of the script expected bytes
- indent_wrap could hang given a non-positive wrap area
- the shebang invoked python, which does not exist on systems that only ship
  python3
This commit is contained in:
2026-07-20 09:23:55 -04:00
parent 61cd1ee1be
commit 468f625df4
3 changed files with 288 additions and 75 deletions
+17
View File
@@ -1,6 +1,23 @@
Change Log Change Log
========== ==========
Unreleased
----------------------------
* New: Interactive full-screen filter UI, on by default when both stdin and stdout are a
terminal. Logs render above a bottom prompt line; typing live-filters the scrollback to
lines containing every typed word, case-insensitively. `--plain` restores the previous
streaming output; piped usage is unaffected.
* Fix: `print(linebuf.encode('utf-8'))` printed the Python 3 bytes repr of every line
instead of the line itself.
* Fix: `named_processes` was still a lazily-exhausted `map()` object after the Python 3
port wrapped `catchall_package` in `list()`.
* Fix: piping `adb logcat` into `pidcat` crashed under Python 3, since `FakeStdinProcess`
read `sys.stdin` (str) where the rest of the script expected bytes.
* Fix: `indent_wrap` could hang given a non-positive wrap area, e.g. an unsized terminal.
* Fix: shebang invoked `python`, which no longer exists on systems that only ship `python3`.
Version 2.1.0 *(2016-09-07)* Version 2.1.0 *(2016-09-07)*
---------------------------- ----------------------------
+18
View File
@@ -20,6 +20,24 @@ Here is an example of the output when running for the Google Plus app:
![Example screen](screen.png) ![Example screen](screen.png)
Interactive mode
-----------------
When both stdin and stdout are a terminal, `pidcat` opens a full-screen filter
UI instead of streaming: logs render above a bottom prompt line, and whatever
you type live-filters the scrollback to lines containing every typed word,
case-insensitively.
* `Backspace` edits the query, `Ctrl-U` clears it.
* `Ctrl-L` forces a redraw; the view also tracks terminal resizes.
* `Ctrl-C` or `Ctrl-D` quits and restores your scrollback.
Pass `--plain` to get the original streaming output instead, e.g. for
`pidcat --plain com.oprah.bees.android | grep Foo`. Piped input or output
(`adb logcat | pidcat com.oprah.bees.android`, `pidcat ... | less`) always
uses plain streaming, since there is no terminal to draw the UI on.
Install Install
------- -------
+186 -8
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env -S python -u #!/usr/bin/env -S python3 -u
''' '''
Copyright 2009, The Android Open Source Project Copyright 2009, The Android Open Source Project
@@ -22,9 +22,15 @@ limitations under the License.
# Package filtering and output improvements by Jake Wharton, http://jakewharton.com # Package filtering and output improvements by Jake Wharton, http://jakewharton.com
import argparse import argparse
import codecs
import collections
import os
import select
import signal
import sys import sys
import re import re
import subprocess import subprocess
import threading
from subprocess import PIPE from subprocess import PIPE
__version__ = '2.1.0' __version__ = '2.1.0'
@@ -46,6 +52,7 @@ parser.add_argument('-t', '--tag', dest='tag', action='append', help='Filter out
parser.add_argument('-i', '--ignore-tag', dest='ignored_tag', action='append', help='Filter output by ignoring specified tag(s)') parser.add_argument('-i', '--ignore-tag', dest='ignored_tag', action='append', help='Filter output by ignoring specified tag(s)')
parser.add_argument('-v', '--version', action='version', version='%(prog)s ' + __version__, help='Print the version number and exit') parser.add_argument('-v', '--version', action='version', version='%(prog)s ' + __version__, help='Print the version number and exit')
parser.add_argument('-a', '--all', dest='all', action='store_true', default=False, help='Print all log messages') parser.add_argument('-a', '--all', dest='all', action='store_true', default=False, help='Print all log messages')
parser.add_argument('--plain', dest='plain', action='store_true', help='Plain streaming output, without the interactive filter UI')
args = parser.parse_args() args = parser.parse_args()
min_level = LOG_LEVELS_MAP[args.min_level.upper()] min_level = LOG_LEVELS_MAP[args.min_level.upper()]
@@ -74,7 +81,7 @@ catchall_package = list(filter(lambda package: package.find(":") == -1, package)
# Store the name of processes to match exactly. # Store the name of processes to match exactly.
named_processes = list(filter(lambda package: package.find(":") != -1, package)) named_processes = list(filter(lambda package: package.find(":") != -1, package))
# Convert default process names from <package>: (cli notation) to <package> (android notation) in the exact names match group. # Convert default process names from <package>: (cli notation) to <package> (android notation) in the exact names match group.
named_processes = map(lambda package: package if package.find(":") != len(package) - 1 else package[:-1], named_processes) named_processes = list(map(lambda package: package if package.find(":") != len(package) - 1 else package[:-1], named_processes))
header_size = args.tag_width + 1 + 3 + 1 # space, level, space header_size = args.tag_width + 1 + 3 + 1 # space, level, space
@@ -102,10 +109,12 @@ def colorize(message, fg=None, bg=None):
return termcolor(fg, bg) + message + RESET if stdout_isatty else message return termcolor(fg, bg) + message + RESET if stdout_isatty else message
def indent_wrap(message): def indent_wrap(message):
if width == -1: wrap_area = width - header_size
# width == -1 means detection failed; a non-positive wrap area (very narrow or
# unsized terminal) would make the loop below never advance, so skip wrapping.
if width == -1 or wrap_area <= 0:
return message return message
message = message.replace('\t', ' ') message = message.replace('\t', ' ')
wrap_area = width - header_size
messagebuf = '' messagebuf = ''
current = 0 current = 0
while current < len(message): while current < len(message):
@@ -193,7 +202,7 @@ if args.clear_logcat:
# This is a ducktype of the subprocess.Popen object # This is a ducktype of the subprocess.Popen object
class FakeStdinProcess(): class FakeStdinProcess():
def __init__(self): def __init__(self):
self.stdout = sys.stdin self.stdout = sys.stdin.buffer
def poll(self): def poll(self):
return None return None
@@ -272,6 +281,14 @@ while True:
seen_pids = True seen_pids = True
pids.add(pid) pids.add(pid)
def stream(emit):
'''Parse adb output and hand each formatted block to emit(search_text, block).
search_text is the block's plain, markup-free text, used by the interactive
filter; block is the colorized output.
'''
global last_tag, app_pid
while adb.poll() is None: while adb.poll() is None:
try: try:
line = adb.stdout.readline().decode('utf-8', 'replace').strip() line = adb.stdout.readline().decode('utf-8', 'replace').strip()
@@ -304,7 +321,7 @@ while adb.poll() is None:
linebuf += colorize(' ' * (header_size - 1), bg=WHITE) linebuf += colorize(' ' * (header_size - 1), bg=WHITE)
linebuf += ' PID: %s UID: %s GIDs: %s' % (line_pid, line_uid, line_gids) linebuf += ' PID: %s UID: %s GIDs: %s' % (line_pid, line_uid, line_gids)
linebuf += '\n' linebuf += '\n'
print(linebuf) emit('Process %s created for %s PID: %s' % (line_package, target, line_pid), linebuf)
last_tag = None # Ensure next log gets a tag printed last_tag = None # Ensure next log gets a tag printed
dead_pid, dead_pname = parse_death(tag, message) dead_pid, dead_pname = parse_death(tag, message)
@@ -314,7 +331,7 @@ while adb.poll() is None:
linebuf += colorize(' ' * (header_size - 1), bg=RED) linebuf += colorize(' ' * (header_size - 1), bg=RED)
linebuf += ' Process %s (PID: %s) ended' % (dead_pname, dead_pid) linebuf += ' Process %s (PID: %s) ended' % (dead_pname, dead_pid)
linebuf += '\n' linebuf += '\n'
print(linebuf) emit('Process %s (PID: %s) ended' % (dead_pname, dead_pid), linebuf)
last_tag = None # Ensure next log gets a tag printed last_tag = None # Ensure next log gets a tag printed
# Make sure the backtrace is printed after a native crash # Make sure the backtrace is printed after a native crash
@@ -333,6 +350,9 @@ while adb.poll() is None:
if args.tag and not tag_in_tags_regex(tag, args.tag): if args.tag and not tag_in_tags_regex(tag, args.tag):
continue continue
# Captured before color markup is added to the message.
search_text = '%s %s %s' % (level, tag, message)
linebuf = '' linebuf = ''
if args.tag_width > 0: if args.tag_width > 0:
@@ -359,4 +379,162 @@ while adb.poll() is None:
message = matcher.sub(replace, message) message = matcher.sub(replace, message)
linebuf += indent_wrap(message) linebuf += indent_wrap(message)
print(linebuf.encode('utf-8')) emit(search_text, linebuf)
class InteractiveUI:
'''Full-screen filter UI: log lines render above a bottom prompt line, and the
typed query live-filters the scrollback. Every whitespace-separated word must
appear in a block's plain text (case-insensitive) for it to be shown.'''
MAX_ENTRIES = 10000 # (search_text, block) pairs kept for re-filtering
MAX_VISIBLE = 5000 # rendered lines kept for the current query
def __init__(self):
self.lock = threading.Lock()
self.entries = collections.deque(maxlen=self.MAX_ENTRIES)
self.visible = []
self.query = ''
self.status = ''
self.resized = False
self.rows, self.cols = self._term_size()
def _term_size(self):
try:
size = os.get_terminal_size(sys.stdout.fileno())
rows, cols = size.lines, size.columns
except OSError:
rows, cols = 24, 80
if rows < 5 or cols < 20:
rows, cols = 24, 80
return rows, cols
def _matches(self, search_lower):
return all(token in search_lower for token in self.query.lower().split())
def _append_visible(self, block):
self.visible.extend(block.split('\n'))
if len(self.visible) > self.MAX_VISIBLE:
del self.visible[:len(self.visible) - self.MAX_VISIBLE]
def emit(self, search_text, block):
with self.lock:
search_lower = search_text.lower()
self.entries.append((search_lower, block))
if self._matches(search_lower):
self._append_visible(block)
self._render()
def set_query(self, query):
with self.lock:
self.query = query
self.visible = []
for search_lower, block in self.entries:
if self._matches(search_lower):
self._append_visible(block)
self._render()
def refresh(self):
global width
with self.lock:
self.rows, self.cols = self._term_size()
width = self.cols # future indent_wrap calls track the new size
self._render()
def _render(self):
log_rows = max(1, self.rows - 2)
lines = self.visible[-log_rows:]
out = ['\x1b[H']
# Pad above so the log content hugs the prompt, like a terminal.
for _ in range(log_rows - len(lines)):
out.append('\x1b[K\n')
for line in lines:
out.append(line + '\x1b[K\n')
if self.query:
state = '%d matching lines of %d blocks' % (len(self.visible), len(self.entries))
else:
state = '%d blocks' % len(self.entries)
if self.status:
state += ' \xb7 ' + self.status
separator = ' %s \xb7 type to filter \xb7 ctrl-u clear \xb7 ctrl-c quit' % state
out.append('\x1b[2m' + separator[:max(0, self.cols - 1)] + '\x1b[0m\x1b[K\n')
out.append('\x1b[36m\x1b[0m ' + self.query + '\x1b[K')
sys.stdout.write(''.join(out))
sys.stdout.flush()
def _drain_pending(self, fd):
# Swallow the rest of an escape sequence (arrow keys etc.) we do not handle.
while select.select([fd], [], [], 0)[0]:
if not os.read(fd, 64):
break
def run(self, reader_thread):
fd = sys.stdin.fileno()
old_attrs = termios.tcgetattr(fd)
decoder = codecs.getincrementaldecoder('utf-8')('replace')
signal.signal(signal.SIGWINCH, lambda *_: setattr(self, 'resized', True))
sys.stdout.write('\x1b[?1049h\x1b[?7l\x1b[2J\x1b[H') # alt screen, no autowrap
sys.stdout.flush()
tty.setcbreak(fd)
reader_thread.start()
try:
with self.lock:
self._render()
while True:
if self.resized:
self.resized = False
self.refresh()
if not reader_thread.is_alive() and not self.status:
self.status = 'adb ended, scrollback still filterable'
with self.lock:
self._render()
if not select.select([fd], [], [], 0.2)[0]:
continue
data = os.read(fd, 64)
if not data:
break
for ch in decoder.decode(data):
if ch in ('\x7f', '\x08'): # backspace
if self.query:
self.set_query(self.query[:-1])
elif ch == '\x15': # ctrl-u
if self.query:
self.set_query('')
elif ch == '\x04': # ctrl-d
return
elif ch == '\x0c': # ctrl-l
self.refresh()
elif ch == '\x1b':
decoder.reset()
self._drain_pending(fd)
break
elif ch in ('\r', '\n', '\t'):
pass
elif ch >= ' ':
self.set_query(self.query + ch)
except KeyboardInterrupt:
pass
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs)
sys.stdout.write('\x1b[?7h\x1b[?1049l')
sys.stdout.flush()
interactive = sys.stdin.isatty() and stdout_isatty and not args.plain
if interactive:
try:
import termios
import tty
except ImportError:
interactive = False # not a POSIX terminal; stream like before
if interactive:
ui = InteractiveUI()
stream_thread = threading.Thread(target=stream, args=(ui.emit,), daemon=True)
ui.run(stream_thread)
else:
if hasattr(signal, 'SIGPIPE'):
# Die quietly like other unix filters when the downstream reader closes,
# e.g. `pidcat --plain <pkg> | head`.
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
stream(lambda search_text, block: print(block))