scripts: Adopted -k/--keep-open in treemap.py/codemap.py

It probably makes sense to adopt this in all ascii-art scripts that
operate on files.
This commit is contained in:
Christopher Haster
2025-03-15 03:49:43 -05:00
parent 6c36f76ffd
commit cb0119681d
3 changed files with 412 additions and 45 deletions
+215 -31
View File
@@ -13,13 +13,21 @@ import bisect
import collections as co import collections as co
import csv import csv
import fnmatch import fnmatch
import io
import itertools as it import itertools as it
import json import json
import math as mt import math as mt
import os
import re import re
import shlex import shlex
import shutil import shutil
import subprocess as sp import subprocess as sp
import time
try:
import inotify_simple
except ModuleNotFoundError:
inotify_simple = None
# we don't actually need that many chars/colors thanks to the # we don't actually need that many chars/colors thanks to the
@@ -53,6 +61,107 @@ def openio(path, mode='r', buffering=-1):
else: else:
return open(path, mode, buffering) return open(path, mode, buffering)
if inotify_simple is None:
Inotify = None
else:
class Inotify(inotify_simple.INotify):
def __init__(self, paths):
super().__init__()
# wait for interesting events
flags = (inotify_simple.flags.ATTRIB
| inotify_simple.flags.CREATE
| inotify_simple.flags.DELETE
| inotify_simple.flags.DELETE_SELF
| inotify_simple.flags.MODIFY
| inotify_simple.flags.MOVED_FROM
| inotify_simple.flags.MOVED_TO
| inotify_simple.flags.MOVE_SELF)
# recurse into directories
for path in paths:
if os.path.isdir(path):
for dir, _, files in os.walk(path):
self.add_watch(dir, flags)
for f in files:
self.add_watch(os.path.join(dir, f), flags)
else:
self.add_watch(path, flags)
class RingIO:
def __init__(self, maxlen=None, head=False):
self.maxlen = maxlen
self.head = head
self.lines = co.deque(maxlen=maxlen)
self.tail = io.StringIO()
# trigger automatic sizing
if maxlen == 0:
self.resize(0)
def __len__(self):
return len(self.lines)
def write(self, s):
# note using split here ensures the trailing string has no newline
lines = s.split('\n')
if len(lines) > 1 and self.tail.getvalue():
self.tail.write(lines[0])
lines[0] = self.tail.getvalue()
self.tail = io.StringIO()
self.lines.extend(lines[:-1])
if lines[-1]:
self.tail.write(lines[-1])
def resize(self, maxlen):
self.maxlen = maxlen
if maxlen == 0:
maxlen = shutil.get_terminal_size((80, 5))[1]
if maxlen != self.lines.maxlen:
self.lines = co.deque(self.lines, maxlen=maxlen)
canvas_lines = 1
def draw(self):
# did terminal size change?
if self.maxlen == 0:
self.resize(0)
# copy lines
lines = self.lines.copy()
# pad to fill any existing canvas, but truncate to terminal size
h = shutil.get_terminal_size((80, 5))[1]
lines.extend('' for _ in range(
len(lines),
min(RingIO.canvas_lines, h)))
while len(lines) > h:
if self.head:
lines.pop()
else:
lines.popleft()
# first thing first, give ourself a canvas
while RingIO.canvas_lines < len(lines):
sys.stdout.write('\n')
RingIO.canvas_lines += 1
# write lines from top to bottom so later lines overwrite earlier
# lines, note [xA/[xB stop at terminal boundaries
for i, line in enumerate(lines):
# move cursor, clear line, disable/reenable line wrapping
sys.stdout.write('\r')
if len(lines)-1-i > 0:
sys.stdout.write('\x1b[%dA' % (len(lines)-1-i))
sys.stdout.write('\x1b[K')
sys.stdout.write('\x1b[?7l')
sys.stdout.write(line)
sys.stdout.write('\x1b[?7h')
if len(lines)-1-i > 0:
sys.stdout.write('\x1b[%dB' % (len(lines)-1-i))
sys.stdout.flush()
def iself(path): def iself(path):
# check for an elf file's magic string (\x7fELF) # check for an elf file's magic string (\x7fELF)
with open(path, 'rb') as f: with open(path, 'rb') as f:
@@ -699,7 +808,7 @@ def collect_ctx(obj_paths, *,
return ctx return ctx
def main(paths, *, def main_(f, paths, *,
namespace_depth=2, namespace_depth=2,
labels=[], labels=[],
chars=[], chars=[],
@@ -718,6 +827,12 @@ def main(paths, *,
label=False, label=False,
no_label=False, no_label=False,
**args): **args):
# give f an writeln function
def writeln(s=''):
f.write(s)
f.write('\n')
f.writeln = writeln
# figure out what color should be # figure out what color should be
if color == 'auto': if color == 'auto':
color = sys.stdout.isatty() color = sys.stdout.isatty()
@@ -748,17 +863,17 @@ def main(paths, *,
# figure out width/height # figure out width/height
if width is None: if width is None:
width_ = min(80, shutil.get_terminal_size((80, 5))[0]) width_ = min(80, shutil.get_terminal_size((80, 5))[0])
elif width: elif width > 0:
width_ = width width_ = width
else: else:
width_ = shutil.get_terminal_size((80, 5))[0] width_ = max(0, shutil.get_terminal_size((80, 5))[0] + width)
if height is None: if height is None:
height_ = 2 if not no_header else 1 height_ = 2 if not no_header else 1
elif height: elif height > 0:
height_ = height height_ = height
else: else:
height_ = shutil.get_terminal_size((80, 5))[1] - 1 height_ = max(0, shutil.get_terminal_size((80, 5))[1] + height)
# try to parse files as CSV/JSON # try to parse files as CSV/JSON
results = [] results = []
@@ -767,25 +882,25 @@ def main(paths, *,
# elf/callgraph files # elf/callgraph files
fs = [] fs = []
for path in paths: for path in paths:
f = openio(path) f_ = openio(path)
if f.buffer.peek(4)[:4] == b'\x7fELF': if f_.buffer.peek(4)[:4] == b'\x7fELF':
for f_ in fs: for f_ in fs:
f_.close() f_.close()
raise StopIteration() raise StopIteration()
fs.append(f) fs.append(f_)
for f in fs: for f_ in fs:
with f: with f_:
# csv or json? assume json starts with [ # csv or json? assume json starts with [
is_json = (f.buffer.peek(1)[:1] == b'[') is_json = (f_.buffer.peek(1)[:1] == b'[')
# read csv? # read csv?
if not is_json: if not is_json:
results.extend(csv.DictReader(f, restval='')) results.extend(csv.DictReader(f_, restval=''))
# read json? # read json?
else: else:
results.extend(json.load(f)) results.extend(json.load(f_))
# fall back to extracting code/stack/ctx info from elf/callgraph files # fall back to extracting code/stack/ctx info from elf/callgraph files
except StopIteration: except StopIteration:
@@ -863,29 +978,29 @@ def main(paths, *,
return f['frame'] + limit return f['frame'] + limit
for k, f in functions.items(): for k, f_ in functions.items():
if 'stack' in f: if 'stack' in f_:
if mt.isinf(f['stack']): if mt.isinf(f_['stack']):
f['limit'] = limitof(k, f) f_['limit'] = limitof(k, f_)
else: else:
f['limit'] = f['stack'] f_['limit'] = f_['stack']
# organize into subsystems # organize into subsystems
namespace_pattern = re.compile('_*[^_]+(?:_*$)?') namespace_pattern = re.compile('_*[^_]+(?:_*$)?')
namespace_slice = slice(namespace_depth if namespace_depth else None) namespace_slice = slice(namespace_depth if namespace_depth else None)
subsystems = {} subsystems = {}
for k, f in functions.items(): for k, f_ in functions.items():
# ignore leading/trailing underscores # ignore leading/trailing underscores
f['subsystem'] = ''.join( f_['subsystem'] = ''.join(
namespace_pattern.findall(k)[ namespace_pattern.findall(k)[
namespace_slice]) namespace_slice])
if f['subsystem'] not in subsystems: if f_['subsystem'] not in subsystems:
subsystems[f['subsystem']] = {'name': f['subsystem']} subsystems[f_['subsystem']] = {'name': f_['subsystem']}
# include ctx in subsystems to give them different colors # include ctx in subsystems to give them different colors
for _, f in functions.items(): for _, f_ in functions.items():
for a in f.get('args', []): for a in f_.get('args', []):
a['subsystem'] = a['name'] a['subsystem'] = a['name']
if a['subsystem'] not in subsystems: if a['subsystem'] not in subsystems:
@@ -1119,9 +1234,9 @@ def main(paths, *,
# print some summary info # print some summary info
if not no_header: if not no_header:
if title: if title:
print(punescape(title, totals['attrs'] | totals)) f.writeln(punescape(title, totals['attrs'] | totals))
else: else:
print('code %d stack %s ctx %d' % ( f.writeln('code %d stack %s ctx %d' % (
totals.get('code', 0), totals.get('code', 0),
(lambda s: '' if mt.isinf(s) else s)( (lambda s: '' if mt.isinf(s) else s)(
totals.get('stack', 0)), totals.get('stack', 0)),
@@ -1130,7 +1245,58 @@ def main(paths, *,
# draw canvas # draw canvas
for row in range(canvas.height//canvas.yscale): for row in range(canvas.height//canvas.yscale):
line = canvas.draw(row) line = canvas.draw(row)
print(line) f.writeln(line)
def main(paths, *,
height=None,
keep_open=False,
head=False,
cat=False,
sleep=False,
**args):
# keep-open?
if keep_open:
try:
while True:
# register inotify before running the command, this avoids
# modification race conditions
if Inotify:
inotify = Inotify(paths)
if cat:
main_(sys.stdout, paths,
# make space for shell prompt
height=height if height is not False else -1,
**args)
else:
ring = RingIO(head=head)
main_(ring, paths,
height=height if height is not False else 0,
**args)
ring.draw()
# try to inotifywait
if Inotify:
ptime = time.time()
inotify.read()
inotify.close()
# sleep a minimum amount of time to avoid flickering
time.sleep(max(0, (sleep or 0.01) - (time.time()-ptime)))
else:
time.sleep(sleep or 2)
except KeyboardInterrupt:
pass
if not cat:
sys.stdout.write('\n')
# single-pass?
else:
main_(sys.stdout, paths,
# make space for shell prompt
height=height if height is not False else -1,
**args)
if __name__ == "__main__": if __name__ == "__main__":
@@ -1238,14 +1404,15 @@ if __name__ == "__main__":
nargs='?', nargs='?',
type=lambda x: int(x, 0), type=lambda x: int(x, 0),
const=0, const=0,
help="Width in columns. 0 uses the terminal width. Defaults to " help="Width in columns. <=0 uses the terminal width. Defaults "
"min(terminal, 80).") "to min(terminal, 80).")
parser.add_argument( parser.add_argument(
'-H', '--height', '-H', '--height',
nargs='?', nargs='?',
type=lambda x: int(x, 0), type=lambda x: int(x, 0),
const=0, const=False,
help="Height in rows. 0 uses the terminal height. Defaults to 1.") help="Height in rows. <=0 uses the terminal height. Defaults "
"to 1.")
parser.add_argument( parser.add_argument(
'--no-header', '--no-header',
action='store_true', action='store_true',
@@ -1347,6 +1514,23 @@ if __name__ == "__main__":
default=CTX_PATH, default=CTX_PATH,
help="Path to the ctx.py script, may include flags. " help="Path to the ctx.py script, may include flags. "
"Defaults to %r." % CTX_PATH) "Defaults to %r." % CTX_PATH)
parser.add_argument(
'-k', '--keep-open',
action='store_true',
help="Continue to open and redraw the CSV files in a loop.")
parser.add_argument(
'-^', '--head',
action='store_true',
help="Show the first n lines.")
parser.add_argument(
'-z', '--cat',
action='store_true',
help="Pipe directly to stdout.")
parser.add_argument(
'-s', '--sleep',
type=float,
help="Time in seconds to sleep between redraws when running "
"with -k. Defaults to 2 seconds.")
sys.exit(main(**{k: v sys.exit(main(**{k: v
for k, v in vars(parser.parse_intermixed_args()).items() for k, v in vars(parser.parse_intermixed_args()).items()
if v is not None})) if v is not None}))
-1
View File
@@ -1070,7 +1070,6 @@ class Grid:
return grid return grid
# TODO adopt this keep_open pattern in all ascii-art scripts?
def main_(f, csv_paths, *, def main_(f, csv_paths, *,
by=None, by=None,
x=None, x=None,
+197 -13
View File
@@ -12,10 +12,18 @@ import bisect
import collections as co import collections as co
import csv import csv
import fnmatch import fnmatch
import io
import itertools as it import itertools as it
import math as mt import math as mt
import os
import re import re
import shutil import shutil
import time
try:
import inotify_simple
except ModuleNotFoundError:
inotify_simple = None
# we don't actually need that many chars/colors thanks to the # we don't actually need that many chars/colors thanks to the
@@ -45,6 +53,107 @@ def openio(path, mode='r', buffering=-1):
else: else:
return open(path, mode, buffering) return open(path, mode, buffering)
if inotify_simple is None:
Inotify = None
else:
class Inotify(inotify_simple.INotify):
def __init__(self, paths):
super().__init__()
# wait for interesting events
flags = (inotify_simple.flags.ATTRIB
| inotify_simple.flags.CREATE
| inotify_simple.flags.DELETE
| inotify_simple.flags.DELETE_SELF
| inotify_simple.flags.MODIFY
| inotify_simple.flags.MOVED_FROM
| inotify_simple.flags.MOVED_TO
| inotify_simple.flags.MOVE_SELF)
# recurse into directories
for path in paths:
if os.path.isdir(path):
for dir, _, files in os.walk(path):
self.add_watch(dir, flags)
for f in files:
self.add_watch(os.path.join(dir, f), flags)
else:
self.add_watch(path, flags)
class RingIO:
def __init__(self, maxlen=None, head=False):
self.maxlen = maxlen
self.head = head
self.lines = co.deque(maxlen=maxlen)
self.tail = io.StringIO()
# trigger automatic sizing
if maxlen == 0:
self.resize(0)
def __len__(self):
return len(self.lines)
def write(self, s):
# note using split here ensures the trailing string has no newline
lines = s.split('\n')
if len(lines) > 1 and self.tail.getvalue():
self.tail.write(lines[0])
lines[0] = self.tail.getvalue()
self.tail = io.StringIO()
self.lines.extend(lines[:-1])
if lines[-1]:
self.tail.write(lines[-1])
def resize(self, maxlen):
self.maxlen = maxlen
if maxlen == 0:
maxlen = shutil.get_terminal_size((80, 5))[1]
if maxlen != self.lines.maxlen:
self.lines = co.deque(self.lines, maxlen=maxlen)
canvas_lines = 1
def draw(self):
# did terminal size change?
if self.maxlen == 0:
self.resize(0)
# copy lines
lines = self.lines.copy()
# pad to fill any existing canvas, but truncate to terminal size
h = shutil.get_terminal_size((80, 5))[1]
lines.extend('' for _ in range(
len(lines),
min(RingIO.canvas_lines, h)))
while len(lines) > h:
if self.head:
lines.pop()
else:
lines.popleft()
# first thing first, give ourself a canvas
while RingIO.canvas_lines < len(lines):
sys.stdout.write('\n')
RingIO.canvas_lines += 1
# write lines from top to bottom so later lines overwrite earlier
# lines, note [xA/[xB stop at terminal boundaries
for i, line in enumerate(lines):
# move cursor, clear line, disable/reenable line wrapping
sys.stdout.write('\r')
if len(lines)-1-i > 0:
sys.stdout.write('\x1b[%dA' % (len(lines)-1-i))
sys.stdout.write('\x1b[K')
sys.stdout.write('\x1b[?7l')
sys.stdout.write(line)
sys.stdout.write('\x1b[?7h')
if len(lines)-1-i > 0:
sys.stdout.write('\x1b[%dB' % (len(lines)-1-i))
sys.stdout.flush()
# parse different data representations # parse different data representations
def dat(x, *args): def dat(x, *args):
try: try:
@@ -709,7 +818,7 @@ def partition_squarify(children, total, x, y, width, height, *,
i = j i = j
def main(csv_paths, *, def main_(f, csv_paths, *,
by=None, by=None,
fields=None, fields=None,
defines=[], defines=[],
@@ -731,6 +840,12 @@ def main(csv_paths, *,
label=False, label=False,
no_label=False, no_label=False,
**args): **args):
# give f an writeln function
def writeln(s=''):
f.write(s)
f.write('\n')
f.writeln = writeln
# figure out what color should be # figure out what color should be
if color == 'auto': if color == 'auto':
color = sys.stdout.isatty() color = sys.stdout.isatty()
@@ -761,20 +876,20 @@ def main(csv_paths, *,
# figure out width/height # figure out width/height
if width is None: if width is None:
width_ = min(80, shutil.get_terminal_size((80, 5))[0]) width_ = min(80, shutil.get_terminal_size((80, 5))[0])
elif width: elif width > 0:
width_ = width width_ = width
else: else:
width_ = shutil.get_terminal_size((80, 5))[0] width_ = max(0, shutil.get_terminal_size((80, 5))[0] + width)
if height is None: if height is None:
height_ = (2 height_ = (2
if not no_header if not no_header
and (title is not None or not no_stats) and (title is not None or not no_stats)
else 1) else 1)
elif height: elif height > 0:
height_ = height height_ = height
else: else:
height_ = shutil.get_terminal_size((80, 5))[1] - 1 height_ = max(0, shutil.get_terminal_size((80, 5))[1] + height)
# first collect results from CSV files # first collect results from CSV files
fields_, results = collect(csv_paths, defines) fields_, results = collect(csv_paths, defines)
@@ -1001,19 +1116,70 @@ def main(csv_paths, *,
stat['mean'], stat['stddev'], stat['mean'], stat['stddev'],
stat['min'], stat['max']) stat['min'], stat['max'])
if title and not no_stats: if title and not no_stats:
print('%s%*s%s' % ( f.writeln('%s%*s%s' % (
title_, title_,
max(width_-len(stat_)-len(title_), 0), ' ', max(width_-len(stat_)-len(title_), 0), ' ',
stat_)) stat_))
elif title: elif title:
print(title_) f.writeln(title_)
elif not no_stats: elif not no_stats:
print(stat_) f.writeln(stat_)
# draw canvas # draw canvas
for row in range(canvas.height//canvas.yscale): for row in range(canvas.height//canvas.yscale):
line = canvas.draw(row) line = canvas.draw(row)
print(line) f.writeln(line)
def main(csv_paths, *,
height=None,
keep_open=False,
head=False,
cat=False,
sleep=False,
**args):
# keep-open?
if keep_open:
try:
while True:
# register inotify before running the command, this avoids
# modification race conditions
if Inotify:
inotify = Inotify(csv_paths)
if cat:
main_(sys.stdout, csv_paths,
# make space for shell prompt
height=height if height is not False else -1,
**args)
else:
ring = RingIO(head=head)
main_(ring, csv_paths,
height=height if height is not False else 0,
**args)
ring.draw()
# try to inotifywait
if Inotify:
ptime = time.time()
inotify.read()
inotify.close()
# sleep a minimum amount of time to avoid flickering
time.sleep(max(0, (sleep or 0.01) - (time.time()-ptime)))
else:
time.sleep(sleep or 2)
except KeyboardInterrupt:
pass
if not cat:
sys.stdout.write('\n')
# single-pass?
else:
main_(sys.stdout, csv_paths,
# make space for shell prompt
height=height if height is not False else -1,
**args)
if __name__ == "__main__": if __name__ == "__main__":
@@ -1103,14 +1269,15 @@ if __name__ == "__main__":
nargs='?', nargs='?',
type=lambda x: int(x, 0), type=lambda x: int(x, 0),
const=0, const=0,
help="Width in columns. 0 uses the terminal width. Defaults to " help="Width in columns. <=0 uses the terminal width. Defaults "
"min(terminal, 80).") "to min(terminal, 80).")
parser.add_argument( parser.add_argument(
'-H', '--height', '-H', '--height',
nargs='?', nargs='?',
type=lambda x: int(x, 0), type=lambda x: int(x, 0),
const=0, const=False,
help="Height in rows. 0 uses the terminal height. Defaults to 1.") help="Height in rows. <=0 uses the terminal height. Defaults "
"to 1.")
parser.add_argument( parser.add_argument(
'--no-header', '--no-header',
action='store_true', action='store_true',
@@ -1198,6 +1365,23 @@ if __name__ == "__main__":
'--no-label', '--no-label',
action='store_true', action='store_true',
help="Don't render any labels.") help="Don't render any labels.")
parser.add_argument(
'-k', '--keep-open',
action='store_true',
help="Continue to open and redraw the CSV files in a loop.")
parser.add_argument(
'-^', '--head',
action='store_true',
help="Show the first n lines.")
parser.add_argument(
'-z', '--cat',
action='store_true',
help="Pipe directly to stdout.")
parser.add_argument(
'-s', '--sleep',
type=float,
help="Time in seconds to sleep between redraws when running "
"with -k. Defaults to 2 seconds.")
sys.exit(main(**{k: v sys.exit(main(**{k: v
for k, v in vars(parser.parse_intermixed_args()).items() for k, v in vars(parser.parse_intermixed_args()).items()
if v is not None})) if v is not None}))