mirror of
https://github.com/imcarlost/pidcat-repl.git
synced 2026-08-08 23:17:43 -04:00
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:
@@ -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)*
|
||||||
----------------------------
|
----------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,24 @@ Here is an example of the output when running for the Google Plus app:
|
|||||||

|

|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
-------
|
-------
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +281,15 @@ while True:
|
|||||||
seen_pids = True
|
seen_pids = True
|
||||||
pids.add(pid)
|
pids.add(pid)
|
||||||
|
|
||||||
while adb.poll() is None:
|
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:
|
||||||
try:
|
try:
|
||||||
line = adb.stdout.readline().decode('utf-8', 'replace').strip()
|
line = adb.stdout.readline().decode('utf-8', 'replace').strip()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user