scripts: Adopted % for escape codes

This is what git --format does, and it's a clever way sidestep the
escape-hell that is bash sometimes.
This commit is contained in:
Christopher Haster
2025-02-12 18:39:48 -06:00
parent dfd9b82619
commit eae7665977
2 changed files with 109 additions and 64 deletions
+50 -27
View File
@@ -14,13 +14,13 @@ if __name__ == "__main__":
__import__('sys').path.pop(0) __import__('sys').path.pop(0)
import bisect import bisect
import codecs
import collections as co import collections as co
import csv import csv
import io import io
import itertools as it import itertools as it
import math as mt import math as mt
import os import os
import re
import shlex import shlex
import shutil import shutil
import sys import sys
@@ -131,9 +131,30 @@ def si2(x, w=5):
s = s.rstrip('.') s = s.rstrip('.')
return '%s%s%s' % ('-' if x < 0 else '', s, SI2_PREFIXES[p]) return '%s%s%s' % ('-' if x < 0 else '', s, SI2_PREFIXES[p])
# parse escape strings # parse %-escaped strings
def escape(s): def unescape(s):
return codecs.escape_decode(s.encode('utf8'))[0].decode('utf8') pattern = re.compile(
'%[%=,abfnrtv0]'
'|' '%x..'
'|' '%u....'
'|' '%U........')
def unescape(m):
if m.group()[1] == '%': return '%'
elif m.group()[1] == '=': return '='
elif m.group()[1] == ',': return ','
elif m.group()[1] == 'a': return '\a'
elif m.group()[1] == 'b': return '\b'
elif m.group()[1] == 'f': return '\f'
elif m.group()[1] == 'n': return '\n'
elif m.group()[1] == 'r': return '\r'
elif m.group()[1] == 't': return '\t'
elif m.group()[1] == 'v': return '\v'
elif m.group()[1] == '0': return '\0'
elif m.group()[1] == 'x': return chr(int(m.group()[2:], 16))
elif m.group()[1] == 'u': return chr(int(m.group()[2:], 16))
elif m.group()[1] == 'U': return chr(int(m.group()[2:], 16))
else: assert False
return re.sub(pattern, unescape, s)
def openio(path, mode='r', buffering=-1): def openio(path, mode='r', buffering=-1):
# allow '-' for stdin/stdout # allow '-' for stdin/stdout
@@ -888,10 +909,10 @@ def main(csv_paths, *,
else: else:
line_chars_ = [False] line_chars_ = [False]
# allow escape codes in labels/titles # allow %-escaped codes in labels/titles
title = escape(title).splitlines() if title is not None else [] title = unescape(title).splitlines() if title is not None else []
xlabel = escape(xlabel).splitlines() if xlabel is not None else [] xlabel = unescape(xlabel).splitlines() if xlabel is not None else []
ylabel = escape(ylabel).splitlines() if ylabel is not None else [] ylabel = unescape(ylabel).splitlines() if ylabel is not None else []
# subplot can also contribute to subplots, resolve this here or things # subplot can also contribute to subplots, resolve this here or things
# become a mess... # become a mess...
@@ -912,8 +933,9 @@ def main(csv_paths, *,
subplots_get('define', **subplot, subplots=subplots)): subplots_get('define', **subplot, subplots=subplots)):
all_defines[k] |= vs all_defines[k] |= vs
all_defines = sorted(all_defines.items()) all_defines = sorted(all_defines.items())
all_labels = ((label or []) all_labels = [(unescape(k), vs) for k, vs in (
+ subplots_get('label', **subplot, subplots=subplots)) (label or [])
+ subplots_get('label', **subplot, subplots=subplots))]
if not all_by and not all_y: if not all_by and not all_y:
print("error: needs --by or -y to figure out fields", print("error: needs --by or -y to figure out fields",
@@ -938,20 +960,22 @@ def main(csv_paths, *,
ysublabel = s.args.get('ylabel') ysublabel = s.args.get('ylabel')
# allow escape codes in sublabels/subtitles # allow escape codes in sublabels/subtitles
subtitle = (escape(subtitle).splitlines() subtitle = (unescape(subtitle).splitlines()
if subtitle is not None else []) if subtitle is not None else [])
xsublabel = (escape(xsublabel).splitlines() xsublabel = (unescape(xsublabel).splitlines()
if xsublabel is not None else []) if xsublabel is not None else [])
ysublabel = (escape(ysublabel).splitlines() ysublabel = (unescape(ysublabel).splitlines()
if ysublabel is not None else []) if ysublabel is not None else [])
# don't allow >2 ticklabels and render single ticklabels only once # don't allow >2 ticklabels and render single ticklabels only once
if xticklabels_ is not None: if xticklabels_ is not None:
xticklabels_ = [unescape(l) for l in xticklabels_]
if len(xticklabels_) == 1: if len(xticklabels_) == 1:
xticklabels_ = ["", xticklabels_[0]] xticklabels_ = ["", xticklabels_[0]]
elif len(xticklabels_) > 2: elif len(xticklabels_) > 2:
xticklabels_ = [xticklabels_[0], xticklabels_[-1]] xticklabels_ = [xticklabels_[0], xticklabels_[-1]]
if yticklabels_ is not None: if yticklabels_ is not None:
yticklabels_ = [unescape(l) for l in yticklabels_]
if len(yticklabels_) == 1: if len(yticklabels_) == 1:
yticklabels_ = ["", yticklabels_[0]] yticklabels_ = ["", yticklabels_[0]]
elif len(yticklabels_) > 2: elif len(yticklabels_) > 2:
@@ -1473,12 +1497,13 @@ if __name__ == "__main__":
action='append', action='append',
type=lambda x: ( type=lambda x: (
lambda k, vs: ( lambda k, vs: (
re.sub(r'\\([=\\])', r'\1', k.strip()), k.strip(),
tuple(v.strip() for v in vs.split(','))) tuple(v.strip() for v in vs.split(',')))
)(*re.split(r'(?<!\\)=', x, 1)), )(*re.split(r'(?<!%)=', x, 1)),
help="Use this label for a given group, where a group is roughly " help="Use this label for a given group, where a group is roughly "
"the comma-separated values in the -b/--by, -x, and -y " "the comma-separated values in the -b/--by, -x, and -y "
"fields. Also provides an ordering. Accepts escaped equals.") "fields. Also provides an ordering. Accepts %= and other "
"%-escaped codes.")
parser.add_argument( parser.add_argument(
'--color', '--color',
choices=['never', 'always', 'auto'], choices=['never', 'always', 'auto'],
@@ -1556,27 +1581,25 @@ if __name__ == "__main__":
help="Units for the y-axis.") help="Units for the y-axis.")
parser.add_argument( parser.add_argument(
'--xlabel', '--xlabel',
help="Add a label to the x-axis.") help="Add a label to the x-axis. Accepts %-escaped codes.")
parser.add_argument( parser.add_argument(
'--ylabel', '--ylabel',
help="Add a label to the y-axis.") help="Add a label to the y-axis. Accepts %-escaped codes.")
parser.add_argument( parser.add_argument(
'--xticklabels', '--xticklabels',
type=lambda x: [re.sub(r'\\([,\\])', r'\1', x.strip()) type=lambda x: [x.strip() for x in re.split(r'(?<!%),', x)]
for x in re.split(r'(?<!\\),', x)]
if x.strip() else [], if x.strip() else [],
help="Comma separated xticklabels. Allows '\,' as an " help="Comma separated xticklabels. Accepts %, and other "
"alternative for a literal ','.") "%-escaped codes.")
parser.add_argument( parser.add_argument(
'--yticklabels', '--yticklabels',
type=lambda x: [re.sub(r'\\([,\\])', r'\1', x.strip()) type=lambda x: [x.strip() for x in re.split(r'(?<!%),', x)]
for x in re.split(r'(?<!\\),', x)]
if x.strip() else [], if x.strip() else [],
help="Comma separated yticklabels. Allows '\,' as an " help="Comma separated yticklabels. Accepts %, and other "
"alternative for a literal ','.") "%-escaped codes.")
parser.add_argument( parser.add_argument(
'-t', '--title', '-t', '--title',
help="Add a title.") help="Add a title. Accepts %-escaped codes.")
parser.add_argument( parser.add_argument(
'-l', '--legend', '--legend-right', '-l', '--legend', '--legend-right',
dest='legend_right', dest='legend_right',
+59 -37
View File
@@ -13,7 +13,6 @@
if __name__ == "__main__": if __name__ == "__main__":
__import__('sys').path.pop(0) __import__('sys').path.pop(0)
import codecs
import collections as co import collections as co
import csv import csv
import io import io
@@ -22,6 +21,7 @@ import logging
import math as mt import math as mt
import numpy as np import numpy as np
import os import os
import re
import shlex import shlex
import shutil import shutil
import sys import sys
@@ -131,9 +131,30 @@ def si2(x):
s = s.rstrip('.') s = s.rstrip('.')
return '%s%s%s' % ('-' if x < 0 else '', s, SI2_PREFIXES[p]) return '%s%s%s' % ('-' if x < 0 else '', s, SI2_PREFIXES[p])
# parse escape strings # parse %-escaped strings
def escape(s): def unescape(s):
return codecs.escape_decode(s.encode('utf8'))[0].decode('utf8') pattern = re.compile(
'%[%=,abfnrtv0]'
'|' '%x..'
'|' '%u....'
'|' '%U........')
def unescape(m):
if m.group()[1] == '%': return '%'
elif m.group()[1] == '=': return '='
elif m.group()[1] == ',': return ','
elif m.group()[1] == 'a': return '\a'
elif m.group()[1] == 'b': return '\b'
elif m.group()[1] == 'f': return '\f'
elif m.group()[1] == 'n': return '\n'
elif m.group()[1] == 'r': return '\r'
elif m.group()[1] == 't': return '\t'
elif m.group()[1] == 'v': return '\v'
elif m.group()[1] == '0': return '\0'
elif m.group()[1] == 'x': return chr(int(m.group()[2:], 16))
elif m.group()[1] == 'u': return chr(int(m.group()[2:], 16))
elif m.group()[1] == 'U': return chr(int(m.group()[2:], 16))
else: assert False
return re.sub(pattern, unescape, s)
# we want to use MaxNLocator, but since MaxNLocator forces multiples of 10 # we want to use MaxNLocator, but since MaxNLocator forces multiples of 10
# to be an option, we can't really... # to be an option, we can't really...
@@ -622,7 +643,7 @@ def main(csv_paths, output, *,
colors_ = COLORS colors_ = COLORS
if formats is not None: if formats is not None:
formats_ = formats formats_ = [unescape(f) for f in formats]
elif points_and_lines: elif points_and_lines:
formats_ = FORMATS_POINTS_AND_LINES formats_ = FORMATS_POINTS_AND_LINES
elif points: elif points:
@@ -727,8 +748,9 @@ def main(csv_paths, output, *,
subplots_get('define', **subplot, subplots=subplots)): subplots_get('define', **subplot, subplots=subplots)):
all_defines[k] |= vs all_defines[k] |= vs
all_defines = sorted(all_defines.items()) all_defines = sorted(all_defines.items())
all_labels = ((label or []) all_labels = [(unescape(k), vs) for k, vs in (
+ subplots_get('label', **subplot, subplots=subplots)) (label or [])
+ subplots_get('label', **subplot, subplots=subplots))]
if not all_by and not all_y: if not all_by and not all_y:
print("error: needs --by or -y to figure out fields", print("error: needs --by or -y to figure out fields",
@@ -874,7 +896,7 @@ def main(csv_paths, output, *,
ax.xaxis.set_major_formatter(lambda x, pos: ax.xaxis.set_major_formatter(lambda x, pos:
si2(x)+(xunits_ if xunits_ else '')) si2(x)+(xunits_ if xunits_ else ''))
if xticklabels_ is not None: if xticklabels_ is not None:
ax.xaxis.set_ticklabels(xticklabels_) ax.xaxis.set_ticklabels([unescape(l) for l in xticklabels_])
if xticks_ is None: if xticks_ is None:
ax.xaxis.set_major_locator(AutoMultipleLocator(2)) ax.xaxis.set_major_locator(AutoMultipleLocator(2))
elif isinstance(xticks_, list): elif isinstance(xticks_, list):
@@ -887,7 +909,7 @@ def main(csv_paths, output, *,
ax.xaxis.set_major_formatter(lambda x, pos: ax.xaxis.set_major_formatter(lambda x, pos:
si(x)+(xunits_ if xunits_ else '')) si(x)+(xunits_ if xunits_ else ''))
if xticklabels_ is not None: if xticklabels_ is not None:
ax.xaxis.set_ticklabels(xticklabels_) ax.xaxis.set_ticklabels([unescape(l) for l in xticklabels_])
if xticks_ is None: if xticks_ is None:
ax.xaxis.set_major_locator(mpl.ticker.AutoLocator()) ax.xaxis.set_major_locator(mpl.ticker.AutoLocator())
elif isinstance(xticks_, list): elif isinstance(xticks_, list):
@@ -900,7 +922,7 @@ def main(csv_paths, output, *,
ax.yaxis.set_major_formatter(lambda x, pos: ax.yaxis.set_major_formatter(lambda x, pos:
si2(x)+(yunits_ if yunits_ else '')) si2(x)+(yunits_ if yunits_ else ''))
if yticklabels_ is not None: if yticklabels_ is not None:
ax.yaxis.set_ticklabels(yticklabels_) ax.yaxis.set_ticklabels([unescape(l) for l in yticklabels_])
if yticks_ is None: if yticks_ is None:
ax.yaxis.set_major_locator(AutoMultipleLocator(2)) ax.yaxis.set_major_locator(AutoMultipleLocator(2))
elif isinstance(yticks_, list): elif isinstance(yticks_, list):
@@ -913,7 +935,7 @@ def main(csv_paths, output, *,
ax.yaxis.set_major_formatter(lambda x, pos: ax.yaxis.set_major_formatter(lambda x, pos:
si(x)+(yunits_ if yunits_ else '')) si(x)+(yunits_ if yunits_ else ''))
if yticklabels_ is not None: if yticklabels_ is not None:
ax.yaxis.set_ticklabels(yticklabels_) ax.yaxis.set_ticklabels([unescape(l) for l in yticklabels_])
if yticks_ is None: if yticks_ is None:
ax.yaxis.set_major_locator(mpl.ticker.AutoLocator()) ax.yaxis.set_major_locator(mpl.ticker.AutoLocator())
elif isinstance(yticks_, list): elif isinstance(yticks_, list):
@@ -927,11 +949,11 @@ def main(csv_paths, output, *,
# axes subplot labels # axes subplot labels
if xsublabel is not None: if xsublabel is not None:
ax.set_xlabel(escape(xsublabel)) ax.set_xlabel(unescape(xsublabel))
if ysublabel is not None: if ysublabel is not None:
ax.set_ylabel(escape(ysublabel)) ax.set_ylabel(unescape(ysublabel))
if subtitle is not None: if subtitle is not None:
ax.set_title(escape(subtitle)) ax.set_title(unescape(subtitle))
# add a legend? a bit tricky with matplotlib # add a legend? a bit tricky with matplotlib
# #
@@ -1002,7 +1024,7 @@ def main(csv_paths, output, *,
# big hack to get xlabel above the legend! but hey this # big hack to get xlabel above the legend! but hey this
# works really well actually # works really well actually
if xlabel: if xlabel:
ax.set_title(escape(xlabel), ax.set_title(unescape(xlabel),
size=plt.rcParams['axes.labelsize'], size=plt.rcParams['axes.labelsize'],
weight=plt.rcParams['axes.labelweight']) weight=plt.rcParams['axes.labelweight'])
@@ -1032,11 +1054,11 @@ def main(csv_paths, output, *,
# axes labels, NOTE we reposition these below # axes labels, NOTE we reposition these below
if xlabel is not None and not legend_below: if xlabel is not None and not legend_below:
fig.supxlabel(escape(xlabel)) fig.supxlabel(unescape(xlabel))
if ylabel is not None: if ylabel is not None:
fig.supylabel(escape(ylabel)) fig.supylabel(unescape(ylabel))
if title is not None: if title is not None:
fig.suptitle(escape(title)) fig.suptitle(unescape(title))
# precompute constrained layout and find midpoints to adjust things # precompute constrained layout and find midpoints to adjust things
# that should be centered so they are actually centered # that should be centered so they are actually centered
@@ -1045,11 +1067,11 @@ def main(csv_paths, output, *,
ymid = (grid[0,0].ax.get_position().y0 + grid[0,-1].ax.get_position().y1)/2 ymid = (grid[0,0].ax.get_position().y0 + grid[0,-1].ax.get_position().y1)/2
if xlabel is not None and not legend_below: if xlabel is not None and not legend_below:
fig.supxlabel(escape(xlabel), x=xmid) fig.supxlabel(unescape(xlabel), x=xmid)
if ylabel is not None: if ylabel is not None:
fig.supylabel(escape(ylabel), y=ymid) fig.supylabel(unescape(ylabel), y=ymid)
if title is not None: if title is not None:
fig.suptitle(escape(title), x=xmid) fig.suptitle(unescape(title), x=xmid)
# write the figure! # write the figure!
@@ -1117,12 +1139,13 @@ if __name__ == "__main__":
action='append', action='append',
type=lambda x: ( type=lambda x: (
lambda k, vs: ( lambda k, vs: (
re.sub(r'\\([=\\])', r'\1', k.strip()), k.strip(),
tuple(v.strip() for v in vs.split(','))) tuple(v.strip() for v in vs.split(',')))
)(*re.split(r'(?<!\\)=', x, 1)), )(*re.split(r'(?<!%)=', x, 1)),
help="Use this label for a given group, where a group is roughly " help="Use this label for a given group, where a group is roughly "
"the comma-separated values in the -b/--by, -x, and -y " "the comma-separated values in the -b/--by, -x, and -y "
"fields. Also provides an ordering. Accepts escaped equals.") "fields. Also provides an ordering. Accepts %= and other "
"%-escaped codes.")
parser.add_argument( parser.add_argument(
'-.', '--points', '-.', '--points',
action='store_true', action='store_true',
@@ -1137,10 +1160,9 @@ if __name__ == "__main__":
help="Comma-separated hex colors to use.") help="Comma-separated hex colors to use.")
parser.add_argument( parser.add_argument(
'--formats', '--formats',
type=lambda x: [re.sub(r'\\([,\\])', r'\1', x.strip()) type=lambda x: [x.strip() for x in re.split(r'(?<!%),', x)],
for x in re.split(r'(?<!\\),', x)], help="Comma-separated matplotlib formats to use. Accepts %, and "
help="Comma-separated matplotlib formats to use. Accepts escaped " "other %-escaped codes.")
"commas.")
parser.add_argument( parser.add_argument(
'-W', '--width', '-W', '--width',
type=lambda x: int(x, 0), type=lambda x: int(x, 0),
@@ -1197,25 +1219,25 @@ if __name__ == "__main__":
help="Units for the y-axis.") help="Units for the y-axis.")
parser.add_argument( parser.add_argument(
'--xlabel', '--xlabel',
help="Add a label to the x-axis.") help="Add a label to the x-axis. Accepts %-escaped codes.")
parser.add_argument( parser.add_argument(
'--ylabel', '--ylabel',
help="Add a label to the y-axis.") help="Add a label to the y-axis. Accepts %-escaped codes.")
parser.add_argument( parser.add_argument(
'--xticklabels', '--xticklabels',
type=lambda x: [re.sub(r'\\([,\\])', r'\1', x.strip()) type=lambda x: [x.strip() for x in re.split(r'(?<!%),', x)]
for x in re.split(r'(?<!\\),', x)]
if x.strip() else [], if x.strip() else [],
help="Comma separated xticklabels. Accepts escaped commas.") help="Comma separated xticklabels. Accepts %, and other "
"%-escaped codes.")
parser.add_argument( parser.add_argument(
'--yticklabels', '--yticklabels',
type=lambda x: [re.sub(r'\\([,\\])', r'\1', x.strip()) type=lambda x: [x.strip() for x in re.split(r'(?<!%),', x)]
for x in re.split(r'(?<!\\),', x)]
if x.strip() else [], if x.strip() else [],
help="Comma separated yticklabels. Accepts escaped commas.") help="Comma separated yticklabels. Accepts %, and other "
"%-escaped codes.")
parser.add_argument( parser.add_argument(
'-t', '--title', '-t', '--title',
help="Add a title.") help="Add a title. Accepts %-escaped codes.")
parser.add_argument( parser.add_argument(
'-l', '--legend', '--legend-right', '-l', '--legend', '--legend-right',
dest='legend_right', dest='legend_right',