scripts: treemap[d3].py: Implemented more flexible labeling/coloring system

Now, instead of specifying a specific field or comma-separated set of
order-defined constants, -L/--add-label, -C/--add-color, and
-./--add-char/--chars accept a by-field group assignment similar to
-L/--label in plotmpl.py.

I also reworked our % modifiers to behave a bit more like printf
modifiers with optional field targets.

It gets a bit complicated, but this ends up extremely flexible:

- Assign to a specific group:

    $ ./scripts/treemap.py -Clfs.c,lfsr_format=orange

- Note this is heirarchical, with more specific groups taking priority:

    $ ./scripts/treemap.py -Clfs.c=blue -Clfs.c,lfsr_format=orange

- We can still get the order-assigned behavior by specifying multiple
  options, but note there is no longer a comma ambiguity! This is useful
  if you want to specify a palette and don't care which dataset gets
  which attr:

    $ ./scripts/treemap.py -Cred -Cgreen -Cblue

- Mix and match:

    $ ./scripts/treemap.py -Cred -Cgreen -Cblue -Clfsr_format=orange

- And with the new % modifiers, we can still use labels stored in a
  field:

    $ ./scripts/treemap.py -L'%(label_field)s'

- -./--add-char/--chars in treemap.py is a bit of a special case. Since
  it only accepts single characters, we can still accept multiple
  options with a single flag without having to worry about ambiguities:

    $ ./scripts/treemap.py -.asdf

  Well, unless you want to include a literal '='. This is possible, but
  a bit messy:

    $ ./scripts/treemap.py -.as -.=== -.df

  Yes that is 3 equal signs... One for argparse, one for the assignment,
  one for the '=' literal.

  This one is minor, but nice for terseness.
This commit is contained in:
Christopher Haster
2025-02-18 18:45:14 -06:00
parent 6a6b74d631
commit baa1a1b3a8
2 changed files with 467 additions and 109 deletions
+252 -57
View File
@@ -13,6 +13,7 @@ import collections as co
import csv
import itertools as it
import math as mt
import re
import shutil
@@ -67,6 +68,12 @@ def dat(x):
# else give up
raise ValueError("invalid dat %r" % x)
def try_dat(x):
try:
return dat(x)
except ValueError:
return None
def collect(csv_paths, defines=[]):
# collect results from CSV files
fields = []
@@ -89,7 +96,7 @@ def collect(csv_paths, defines=[]):
return fields, results
def fold(results, by=None, fields=None, labels=None, defines=[]):
def fold(results, by=None, fields=None, defines=[]):
# filter by matching defines
if defines:
results_ = []
@@ -105,14 +112,14 @@ def fold(results, by=None, fields=None, labels=None, defines=[]):
keys.add(tuple(r.get(k, '') for k in by))
keys = sorted(keys)
# collect dataset
# collect datasets
datasets = co.OrderedDict()
labels_ = co.OrderedDict()
dataattrs = co.OrderedDict()
for key in (keys if by else [()]):
for field in fields:
# organize by 'by' and field
dataset = []
label = None
dataattr = {}
for r in results:
# filter by 'by'
if by and not all(
@@ -135,21 +142,141 @@ def fold(results, by=None, fields=None, labels=None, defines=[]):
# incorrect and misleading results
dataset.append(v)
# also find label?
if labels is not None:
for label_ in labels:
if label_ in r:
label = r[label_]
# include all fields in dataattrs in case we use
# them for % modifiers
dataattr.update(r)
# hide 'field' if there is only one field
key_ = key
if len(fields or []) > 1 or not key_:
key_ += (field,)
datasets[key_] = dataset
if label is not None:
labels_[key_] = label
dataattrs[key_] = dataattr
return datasets, labels_
return datasets, dataattrs
# a representation of optionally key-mapped attrs
class Attr:
def __init__(self, attrs, *,
defaults=None):
# include defaults?
if (defaults is not None
and not any(
not isinstance(attr, tuple) or attr[0] is None
for attr in (attrs or []))):
attrs = defaults + (attrs or [])
# normalize and split out keyed vs indexed attrs
self.attrs = []
self.indexed = []
self.keyed = []
for attr in (attrs or []):
if not isinstance(attr, tuple):
attr = (None, attr)
self.attrs.append(attr)
if attr[0] is None:
self.indexed.append(attr[1])
else:
self.keyed.append(attr)
def __repr__(self):
return 'Attr(%r)' % [
(','.join(key), a) if key is not None else a
for key, a in self.attrs]
def __iter__(self):
return it.cycle(self.indexed)
def __len__(self):
return len(self.indexed)
def __bool__(self):
# note this is not just the indexed attrs
return bool(self.attrs)
def lookup(self, key):
# try to lookup by key
best = None
for attr in self.keyed:
prefix = []
for i, k in enumerate(attr[0]):
if i < len(key) and (not k or key[i] == k):
prefix.append(k)
else:
prefix = None
break
if prefix is not None and (
best is None or len(prefix) >= len(best[0])):
best = (prefix, attr[1])
if best is not None:
return best[1]
else:
return None
def __getitem__(self, key):
if isinstance(key, tuple):
if len(key) > 0 and not isinstance(key[0], str):
i, key = key
else:
i, key = None, key
else:
i, key = key, None
# try to lookup by key
if key is not None:
attr = self.lookup(key)
if attr is not None:
return attr
# otherwise fallback to index
if i is not None and self.indexed:
return self.indexed[i % len(self.indexed)]
return None
def __contains__(self, key):
return self.__getitem__(key) is not None
# parse %-escaped strings
def punescape(s, attrs=None):
if attrs is None:
attrs = {}
if isinstance(attrs, dict):
attrs_ = attrs
attrs = lambda k: attrs_[k]
pattern = re.compile(
'%[%n]'
'|' '%x..'
'|' '%u....'
'|' '%U........'
'|' '%\((?P<field>[^)]*)\)'
'(?P<format>[+\- #0-9\.]*[scdboxXfFeEgG])')
def unescape(m):
if m.group()[1] == '%': return '%'
elif m.group()[1] == 'n': return '\n'
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))
elif m.group()[1] == '(':
try:
v = attrs(m.group('field'))
except KeyError:
return m.group()
if m.group('format')[-1] in 'dboxXfFeEgG':
if isinstance(v, str):
v = try_dat(v) or 0
else:
if not isinstance(v, str):
v = str(v)
# note we need Python's new format syntax for binary
f = '{:%s}' % m.group('format')
return f.format(v)
else: assert False
return re.sub(pattern, unescape, s)
# a little ascii renderer
@@ -257,7 +384,7 @@ class Canvas:
for i in range(w):
self.point(x+i, y+j, char=char, color=color)
def label(self, x, y, label, *,
def label(self, x, y, label, width=None, height=None, *,
color=''):
# scale if needed
if self.braille:
@@ -267,8 +394,17 @@ class Canvas:
else:
xscale, yscale = 1, 1
for i, char in enumerate(label):
self.point(x+i*xscale, y, char=char, color=color)
x_ = x
y_ = y
for char in label:
if char == '\n':
x_ = x
y_ -= 1
else:
if ((width is None or x_ < x+width)
and (height is None or y_ > y-height)):
self.point(x_, y_, char=char, color=color)
x_ += xscale
def draw(self, row):
# scale if needed
@@ -326,6 +462,7 @@ class Tile:
def __init__(self, key, children,
x=None, y=None, width=None, height=None, *,
depth=None,
attrs=None,
label=None,
color=None):
self.key = key
@@ -341,6 +478,7 @@ class Tile:
self.width = width
self.height = height
self.depth = depth
self.attrs = attrs
self.label = label
self.color = color
@@ -562,13 +700,13 @@ def partition_squarify(children, total, x, y, width, height, *,
def main(csv_paths, *,
by=None,
fields=None,
labels=None,
defines=[],
labels=None,
chars=None,
colors=None,
color=False,
dots=False,
braille=False,
chars=None,
colors=None,
width=None,
height=None,
no_header=False,
@@ -576,6 +714,7 @@ def main(csv_paths, *,
aspect_ratio=(1,1),
title=None,
padding=0,
label=False,
**args):
# figure out what color should be
if color == 'auto':
@@ -585,16 +724,20 @@ def main(csv_paths, *,
else:
color = False
# figure out chars/colors
if chars is not None:
chars_ = chars
else:
chars_ = CHARS
# what chars/colors/labels to use?
chars_ = []
for char in chars:
if isinstance(char, tuple):
for char_ in char[1]:
chars_.append((char[0], char_))
else:
for char_ in char:
chars_.append(char_)
chars_ = Attr(chars_, defaults=CHARS)
if colors is not None:
colors_ = colors
else:
colors_ = COLORS
colors_ = Attr(colors, defaults=COLORS)
labels_ = Attr(labels)
# figure out width/height
if width is None:
@@ -634,7 +777,7 @@ def main(csv_paths, *,
and not any(k == k_ for k_, _ in defines)]
# then extract the requested dataset
datasets, labels_ = fold(results, by, fields, labels, defines)
datasets, dataattrs = fold(results, by, fields, defines)
# build tile heirarchy
children = []
@@ -643,24 +786,35 @@ def main(csv_paths, *,
children.append(Tile(
key + ((str(i),) if len(dataset) > 1 else ()),
v,
label=labels_.get(key)))
attrs=dataattrs[key]))
tile = Tile.merge(children)
# sort
tile.sort()
# merge attrs
for t in tile.tiles():
if t.children:
t.attrs = {k: v
for t_ in t.leaves()
for k, v in t_.attrs.items()}
# also sum fields here in case they're used by % modifiers,
# note other fields are _not_ summed
for k in fields:
t.attrs[k] = sum(t_.value
for t_ in t.leaves()
if len(fields) == 1 or t_.key[len(by)] == k)
# assign colors/chars after sorting to try to minimize touching
# colors, while keeping things somewhat reproducible
# assign colors/labels before sorting to keep things reproducible
# use colors for top of tree
for i, t in enumerate(tile.children):
for t_ in t.tiles():
t_.color = colors_[i % len(colors_)]
t_.color = colors_[i, t.key]
# and chars for bottom of tree
# and chars/labels for bottom of tree
for i, t in enumerate(tile.leaves()):
t.char = chars_[i % len(chars_)]
t.char = chars_[i, t.key]
if (i, t.key) in labels_:
t.label = punescape(labels_[i, t.key], t.attrs)
# scale width/height if requested now that we have our data
if to_scale and (width is None or height is None) and tile.value != 0:
@@ -698,18 +852,25 @@ def main(csv_paths, *,
dots=dots,
braille=braille)
# sort
tile.sort()
# recursively partition tiles
tile.x = 0
tile.y = 0
tile.width = canvas.width
tile.height = canvas.height
def partition(tile):
# apply top padding
if tile.depth == 0:
# apply top padding
tile.x += padding
tile.y += padding
tile.width -= min(padding, tile.width)
tile.height -= min(padding, tile.height)
# apply bottom padding
if not tile.children:
tile.width -= min(padding, tile.width)
tile.height -= min(padding, tile.height)
x__ = tile.x
y__ = tile.y
@@ -769,7 +930,7 @@ def main(csv_paths, *,
tile.align()
# render to canvas
labels_ = []
labels__ = []
for t in tile.leaves():
x__ = t.x
y__ = t.y
@@ -795,19 +956,22 @@ def main(csv_paths, *,
else t.char if t.char is not None else chars_[0]),
color=t.color if t.color is not None else colors_[0])
if labels:
if label:
if t.label is not None:
label__ = t.label
else:
label__ = ','.join(t.key)
# render these later so they get priority
labels_.append((x__, y__+height__-1, label__[:width__]))
labels__.append((x__, y__+height__-1, label__,
width__, height__))
for x__, y__, label__ in labels_:
canvas.label(x__, y__, label__)
for label__ in labels__:
canvas.label(*label__)
# print some summary info
if title:
title_ = punescape(title, tile.attrs)
if not no_header:
stat = tile.stat()
stat_ = 'total %d, avg %d +-%dσ, min %d, max %d' % (
@@ -815,9 +979,12 @@ def main(csv_paths, *,
stat['mean'], stat['stddev'],
stat['min'], stat['max'])
if title and not no_header:
print('%s%*s%s' % (title, width_-len(stat_)-len(title), '', stat_))
print('%s%*s%s' % (
title_,
max(width_-len(stat_)-len(title_), 0), ' ',
stat_))
elif title:
print(title)
print(title_)
elif not no_header:
print(stat_)
@@ -846,12 +1013,6 @@ if __name__ == "__main__":
dest='fields',
action='append',
help="Field to use for tile sizes.")
parser.add_argument(
'-l', '--label',
nargs='?',
dest='labels',
action='append',
help="Field to use as tile label.")
parser.add_argument(
'-D', '--define',
dest='defines',
@@ -862,6 +1023,43 @@ if __name__ == "__main__":
{v.strip() for v in vs.split(',')})
)(*x.split('=', 1)),
help="Only include results where this field is this value.")
parser.add_argument(
'-L', '--add-label',
dest='labels',
action='append',
type=lambda x: (
lambda key, v: (
tuple(k.strip() for k in key.split(',')),
v.strip())
)(*x.split('=', 1))
if '=' in x else x.strip(),
help="Add a label to use. Can be assigned to a specific group "
"where a group is the comma-separated 'by' fields. Accepts %% "
"modifiers.")
parser.add_argument(
'-.', '--add-char', '--chars',
dest='chars',
action='append',
type=lambda x: (
lambda key, v: (
tuple(k.strip() for k in key.split(',')),
v.strip())
)(*x.split('=', 1))
if '=' in x else x.strip(),
help="Add characters to use. Can be assigned to a specific group "
"where a group is the comma-separated 'by' fields.")
parser.add_argument(
'-C', '--add-color',
dest='colors',
action='append',
type=lambda x: (
lambda key, v: (
tuple(k.strip() for k in key.split(',')),
v.strip())
)(*x.split('=', 1))
if '=' in x else x.strip(),
help="Add a color to use. Can be assigned to a specific group "
"where a group is the comma-separated 'by' fields.")
parser.add_argument(
'--color',
choices=['never', 'always', 'auto'],
@@ -876,13 +1074,6 @@ if __name__ == "__main__":
action='store_true',
help="Use 2x4 unicode braille characters. Note that braille "
"characters sometimes suffer from inconsistent widths.")
parser.add_argument(
'--chars',
help="Characters to use for tiles.")
parser.add_argument(
'--colors',
type=lambda x: [x.strip() for x in x.split(',')],
help="Colors to use for tiles.")
parser.add_argument(
'-W', '--width',
nargs='?',
@@ -958,6 +1149,10 @@ if __name__ == "__main__":
type=float,
default=0,
help="Padding to add to each level of the treemap. Defaults to 0.")
parser.add_argument(
'-l', '--label',
action='store_true',
help="Render labels.")
sys.exit(main(**{k: v
for k, v in vars(parser.parse_intermixed_args()).items()
if v is not None}))
+215 -52
View File
@@ -13,6 +13,7 @@ import collections as co
import csv
import itertools as it
import math as mt
import re
import shutil
@@ -83,6 +84,12 @@ def dat(x):
# else give up
raise ValueError("invalid dat %r" % x)
def try_dat(x):
try:
return dat(x)
except ValueError:
return None
def collect(csv_paths, defines=[]):
# collect results from CSV files
fields = []
@@ -105,7 +112,7 @@ def collect(csv_paths, defines=[]):
return fields, results
def fold(results, by=None, fields=None, labels=None, defines=[]):
def fold(results, by=None, fields=None, defines=[]):
# filter by matching defines
if defines:
results_ = []
@@ -121,14 +128,14 @@ def fold(results, by=None, fields=None, labels=None, defines=[]):
keys.add(tuple(r.get(k, '') for k in by))
keys = sorted(keys)
# collect dataset
# collect datasets
datasets = co.OrderedDict()
labels_ = co.OrderedDict()
dataattrs = co.OrderedDict()
for key in (keys if by else [()]):
for field in fields:
# organize by 'by' and field
dataset = []
label = None
dataattr = {}
for r in results:
# filter by 'by'
if by and not all(
@@ -151,21 +158,142 @@ def fold(results, by=None, fields=None, labels=None, defines=[]):
# incorrect and misleading results
dataset.append(v)
# also find label?
if labels is not None:
for label_ in labels:
if label_ in r:
label = r[label_]
# include all fields in dataattrs in case we use
# them for % modifiers
dataattr.update(r)
# hide 'field' if there is only one field
key_ = key
if len(fields or []) > 1 or not key_:
key_ += (field,)
datasets[key_] = dataset
if label is not None:
labels_[key_] = label
dataattrs[key_] = dataattr
return datasets, dataattrs
# a representation of optionally key-mapped attrs
class Attr:
def __init__(self, attrs, *,
defaults=None):
# include defaults?
if (defaults is not None
and not any(
not isinstance(attr, tuple) or attr[0] is None
for attr in (attrs or []))):
attrs = defaults + (attrs or [])
# normalize and split out keyed vs indexed attrs
self.attrs = []
self.indexed = []
self.keyed = []
for attr in (attrs or []):
if not isinstance(attr, tuple):
attr = (None, attr)
self.attrs.append(attr)
if attr[0] is None:
self.indexed.append(attr[1])
else:
self.keyed.append(attr)
def __repr__(self):
return 'Attr(%r)' % [
(','.join(key), a) if key is not None else a
for key, a in self.attrs]
def __iter__(self):
return it.cycle(self.indexed)
def __len__(self):
return len(self.indexed)
def __bool__(self):
# note this is not just the indexed attrs
return bool(self.attrs)
def lookup(self, key):
# try to lookup by key
best = None
for attr in self.keyed:
prefix = []
for i, k in enumerate(attr[0]):
if i < len(key) and (not k or key[i] == k):
prefix.append(k)
else:
prefix = None
break
if prefix is not None and (
best is None or len(prefix) >= len(best[0])):
best = (prefix, attr[1])
if best is not None:
return best[1]
else:
return None
def __getitem__(self, key):
if isinstance(key, tuple):
if len(key) > 0 and not isinstance(key[0], str):
i, key = key
else:
i, key = None, key
else:
i, key = key, None
# try to lookup by key
if key is not None:
attr = self.lookup(key)
if attr is not None:
return attr
# otherwise fallback to index
if i is not None and self.indexed:
return self.indexed[i % len(self.indexed)]
return None
def __contains__(self, key):
return self.__getitem__(key) is not None
# parse %-escaped strings
def punescape(s, attrs=None):
if attrs is None:
attrs = {}
if isinstance(attrs, dict):
attrs_ = attrs
attrs = lambda k: attrs_[k]
pattern = re.compile(
'%[%n]'
'|' '%x..'
'|' '%u....'
'|' '%U........'
'|' '%\((?P<field>[^)]*)\)'
'(?P<format>[+\- #0-9\.]*[scdboxXfFeEgG])')
def unescape(m):
if m.group()[1] == '%': return '%'
elif m.group()[1] == 'n': return '\n'
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))
elif m.group()[1] == '(':
try:
v = attrs(m.group('field'))
except KeyError:
return m.group()
if m.group('format')[-1] in 'dboxXfFeEgG':
if isinstance(v, str):
v = try_dat(v) or 0
else:
if not isinstance(v, str):
v = str(v)
# note we need Python's new format syntax for binary
f = '{:%s}' % m.group('format')
return f.format(v)
else: assert False
return re.sub(pattern, unescape, s)
return datasets, labels_
# a type to represent tiles
@@ -173,6 +301,7 @@ class Tile:
def __init__(self, key, children,
x=None, y=None, width=None, height=None, *,
depth=None,
attrs=None,
label=None,
color=None):
self.key = key
@@ -188,6 +317,7 @@ class Tile:
self.width = width
self.height = height
self.depth = depth
self.attrs = attrs
self.label = label
self.color = color
@@ -410,8 +540,8 @@ def main(csv_paths, output, *,
quiet=False,
by=None,
fields=None,
labels=None,
defines=[],
labels=None,
colors=None,
width=None,
height=None,
@@ -434,13 +564,10 @@ def main(csv_paths, output, *,
no_header = True
no_label = True
# what colors to use?
if colors is not None:
colors_ = colors
elif dark:
colors_ = COLORS_DARK
else:
colors_ = COLORS
# what colors/labels to use?
colors_ = Attr(colors, defaults=COLORS_DARK if dark else COLORS)
labels_ = Attr(labels)
if background is not None:
background_ = background
@@ -483,7 +610,7 @@ def main(csv_paths, output, *,
and not any(k == k_ for k_, _ in defines)]
# then extract the requested dataset
datasets, labels_ = fold(results, by, fields, labels, defines)
datasets, dataattrs = fold(results, by, fields, defines)
# build tile heirarchy
children = []
@@ -492,20 +619,34 @@ def main(csv_paths, output, *,
children.append(Tile(
key + ((str(i),) if len(dataset) > 1 else ()),
v,
label=labels_.get(key)))
attrs=dataattrs[key]))
tile = Tile.merge(children)
# sort
tile.sort()
# merge attrs
for t in tile.tiles():
if t.children:
t.attrs = {k: v
for t_ in t.leaves()
for k, v in t_.attrs.items()}
# also sum fields here in case they're used by % modifiers,
# note other fields are _not_ summed
for k in fields:
t.attrs[k] = sum(t_.value
for t_ in t.leaves()
if len(fields) == 1 or t_.key[len(by)] == k)
# assign colors after sorting to try to minimize touching
# colors, while keeping things somewhat reproducible
# assign colors/labels before sorting to keep things reproducible
# use colors for top of tree
for i, t in enumerate(tile.children):
for t_ in t.tiles():
t_.color = colors_[i % len(colors_)]
t_.color = colors_[i, t_.key]
# and labels everywhere
for i, t in enumerate(tile.tiles()):
if (i, t.key) in labels_:
t.label = punescape(labels_[i, t.key], t.attrs)
# scale width/height if requested now that we have our data
if to_scale and (width is None or height is None) and tile.value != 0:
@@ -521,6 +662,9 @@ def main(csv_paths, output, *,
* (aspect_ratio[0] / aspect_ratio[1]))
height_ = mt.ceil((tile.value * to_scale) / width_)
# sort
tile.sort()
# recursively partition tiles
tile.x = 0
tile.y = 0
@@ -569,7 +713,6 @@ def main(csv_paths, output, *,
if nested:
y__ += mt.ceil(FONT_SIZE * 1.3)
height__ -= min(mt.ceil(FONT_SIZE * 1.3), height__)
# partition via requested scheme
if tile.children:
@@ -638,7 +781,7 @@ def main(csv_paths, output, *,
stat = tile.stat()
if title:
f.write('<tspan x="3" y="1.1em">')
f.write(title)
f.write(punescape(title, tile.attrs))
f.write('</tspan>')
if not no_header:
f.write('<tspan x="%(x)d" y="1.1em" '
@@ -670,11 +813,11 @@ def main(csv_paths, output, *,
if t.label is not None:
label__ = t.label
else:
label__ = ','.join(t.key)
label__ = '%s\n%d' % (','.join(t.key), t.value)
f.write('<g transform="translate(%d,%d)">' % (t.x, t.y))
f.write('<title>')
f.write('\n'.join([label__, str(t.value)]))
f.write(label__)
f.write('</title>')
f.write('<rect '
'id="tile-%(id)s" '
@@ -692,17 +835,22 @@ def main(csv_paths, output, *,
f.write('</use>')
f.write('</clipPath>')
f.write('<text clip-path="url(#clip-%s)">' % i)
f.write('<tspan x="3" y="1.1em">')
f.write(label__)
f.write('</tspan>')
if t.children:
f.write('<tspan dx="3" y="1.1em" fill-opacity="0.7">')
f.write(str(t.value))
f.write('</tspan>')
else:
f.write('<tspan x="3" y="2.2em" fill-opacity="0.7">')
f.write(str(t.value))
f.write('</tspan>')
for j, l in enumerate(label__.split('\n')):
if j == 0:
f.write('<tspan x="3" y="1.1em">')
f.write(l)
f.write('</tspan>')
else:
if t.children:
f.write('<tspan dx="3" y="1.1em" '
'fill-opacity="0.7">')
f.write(l)
f.write('</tspan>')
else:
f.write('<tspan x="3" dy="1.1em" '
'fill-opacity="0.7">')
f.write(l)
f.write('</tspan>')
f.write('</text>')
f.write('</g>')
@@ -745,12 +893,6 @@ if __name__ == "__main__":
dest='fields',
action='append',
help="Field to use for tile sizes.")
parser.add_argument(
'-l', '--label',
nargs='?',
dest='labels',
action='append',
help="Field to use as tile label.")
parser.add_argument(
'-D', '--define',
dest='defines',
@@ -762,9 +904,30 @@ if __name__ == "__main__":
)(*x.split('=', 1)),
help="Only include results where this field is this value.")
parser.add_argument(
'--colors',
type=lambda x: [x.strip() for x in x.split(',')],
help="Comma-separated hex colors to use.")
'-L', '--add-label',
dest='labels',
action='append',
type=lambda x: (
lambda key, v: (
tuple(k.strip() for k in key.split(',')),
v.strip())
)(*x.split('=', 1))
if '=' in x else x.strip(),
help="Add a label to use. Can be assigned to a specific group "
"where a group is the comma-separated 'by' fields. Accepts %% "
"modifiers.")
parser.add_argument(
'-C', '--add-color',
dest='colors',
action='append',
type=lambda x: (
lambda key, v: (
tuple(k.strip() for k in key.split(',')),
v.strip())
)(*x.split('=', 1))
if '=' in x else x.strip(),
help="Add a color to use. Can be assigned to a specific group "
"where a group is the comma-separated 'by' fields.")
parser.add_argument(
'-W', '--width',
type=lambda x: int(x, 0),
@@ -847,7 +1010,7 @@ if __name__ == "__main__":
parser.add_argument(
'--no-label',
action='store_true',
help="Don't render any labels or text.")
help="Don't render any labels.")
parser.add_argument(
'--dark',
action='store_true',