termgraph を自分のスクリプトから使いたい

ターミナルに簡単にグラフを書きたいと思って PyPI を探してみたら termgraph というのを見つけたものの、README.md を見ると何やらコマンドとして使うのが想定されているようで Python スクリプトから呼び出す方法については書かれていなかった。 仕方ないので termgraph の中を見てみて、こんな感じでやれば自分の Python スクリプトから termgraph 内の関数を呼び出してグラフを書けるよ、という内容のメモ。

termgraph に付属のサンプルデータにある data/ex4.txt を例にする。

# Example Data Set 4 with 2 Categories
@ Boys,Girls
2007,183.32,190.52
2008,231.23,5.0
2009,16.43,53.1
2010,50.21,7
2011,508.97,10.45
2012,212.05,20.2
2014,30.0,20.0
  • 2007 年から 2014 年までの、Boys と Girls についてのなんらかの値が入っている。
  • # で始まる行はコメント
  • @ で始まる行は、データ行の列名だと思っておけば良い。termgraph の中では categories と呼ばれている
  • 以降の行は、label,boys,girls という並びでカンマ区切りでデータが置かれている

お手本

termgraph コマンドを使うと、こんなグラフが書ける。

% termgraph ~/src-misc/termgraph/data/ex4.dat 

▇ Boys  ▇ Girls  


2007: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 183.32
      ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 190.52
2008: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 231.23
      ▏ 5.00 
2009: ▇ 16.43
      ▇▇▇▇▇ 53.10
2010: ▇▇▇▇ 50.21
      ▏ 7.00 
2011: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 508.97
      ▇ 10.45
2012: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 212.05
      ▇ 20.20
2014: ▇▇ 30.00
      ▇ 20.00

% 

コマンドを使うのではなく、自分の Python スクリプトから termgraph の中のものを使って同じことをやろう、というのが目標。

概要

データを用意して、以下の termgraph 内の関数を呼び出す。

  • termgraph.check_data
  • termgraph.print_categories
  • termgraph.chart

termgraph.chart 関数

最終的に termgraph.chart 関数を呼んでグラフを書くことになる。

def chart(colors: List, data: List, args: Dict, labels: List) -> None:
    """Handle the normalization of data and the printing of the graph."""

colors, data, args, labels を用意する必要がある。

  • colors - チャートの色の list
  • data - チャートのデータの list
  • args - termgraph.chart 関数のオプションを指定する dict
  • labels - 各要素の名前

colors リスト

上の例では Boys / Girls ともに端末の文字の色でグラフが描かれるが、termgraph コマンドに --color {blue,red} とオプションを付け加えると、Boys は青で Girls は赤でグラフが描かれる。

この colors リストは自分で用意する必要はない。termgraph.check_data 関数の返り値を colors にする。

colors = tg.check_data(labels, data, args)

では、どのように色を指定するのかというと、args['color'] に値を入れておく。端末の文字色でよければ None を入れる。categories の要素ごとに色を変えたい場合リストに色を指定する、例えば Boys は青で Girls は赤にしたいという場合には、args['color'] = ['blue', 'red'] という感じ。このリストの要素数は、categories リストの要素数と等しくなくてはならない。

指定できる色は、termgraph.AVAILABLE_COLORS に定義されている。

# ANSI escape SGR Parameters color codes
AVAILABLE_COLORS = {
    "red": 91,
    "blue": 94,
    "green": 92,
    "magenta": 95,
    "yellow": 93,
    "black": 90,
    "cyan": 96,
}

色を追加で定義したいのなら、この AVAILABLE_CHARTS に追加すれば良い。例えば白なら tg.AVAILABLE_COLORS = {'white': 97} と書く。 wikipediaANSI escape code のページを見ると、30 から 37 の値も使えそうではあるが試してはいない。

labels リスト

2007 から 2014 までの文字列のリストを作っておけば良い。

labels = ['2007', '2008', '2009', '2010', '2011', '2022', '2014']

data リスト

[boysの値,girlsの値] のリストを作っておけば良い。こんな感じ。

data = [
    # Boys,  Girls
    [183.32, 190.52],
    [231.23,   5.0 ],
    [ 16.43,  53.1],
    [ 50.21,   7  ],
    [508.97,  10.45],
    [212.05,  20.2 ],
    [ 30.0,   20.0 ],
]

args dict

termgraph コマンドのヘルプ (termgraph --help) を参考に dict を作っていけば良いのだが、termgraph.chart 関数や termgraph.check_data 関数を呼ぶだけなら不要だったりするものや、そもそも使われていないものもある。。。

termgraph.chart を呼ぶために必須なもの

オプション名 タイプ オプションの意味
width int グラフの幅。デフォルト 50。
format str 各棒の後に数値を表示する際の形式。デフォルト {:<5.2f}
suffix str 各棒の後に表示した数値の後につける文字列。 % とかをつけたいなら。 
no_labels bool 各棒の先頭にラベルを表示するかどうか。
no_values bool 各棒の後に値を表示するかどうか。
color list 色名をリストで指定。 ["blue", "red"] とか。指定がなければ None を入れておけば良い。
vertical bool 横ではなく縦にグラフを書きたい!
stacked bool 各カテゴリを合わせたグラフにしたい!
histogram bool ヒストグラムでお願い!
bins int ヒストグラムにしたときの bin の数。 デフォルト 5。
different_scale bool Categories have different scales らしいが調べてない

termgraph コマンドのオプションとして定義されているが、chart や check_data を使うにあたっては不要なもの

  • title
  • calendar
  • start-dt
  • custom-tick
  • delim

タイトルは自分で書けばいい。

calendar と start-dt は、カレンダーヒートマップを書きたい場合に使う。termgraph.chart 関数ではなく、termgraph.calendar_heatmap 関数を呼ぶ。

custom-tick は、グラフを描くときの文字を変更したい場合に使う。

delim は、データ入力をファイルから行うときに区切りもじを指定するために使う。(termgraph.DELIM の値を変える。)

termgraph コマンドのオプションとして定義されているが使われていないもの...

  • space-between
  • label-before

というわけで、こんな感じで args を用意すればよい。

args = {
    'width': 50,
    'format': '{:<5.2f}',
    'suffix': '',
    'no_labels': False,
    'no_values': False,
    'color': None,
    'vertical': False,
    'stacked': False,
    'histogram': False,
    'bins': 5,
    'different_scale': False
}

categories リスト

凡例を表示するには、termgraph.print_categories(categories, colors) を呼ぶ。 categories リストは ['Boys', 'Girls'] として用意する。

組み合わせてみる

全部合わせてみると、こんな感じのスクリプトになる。

from termgraph import termgraph as tg

# data/ex4.dat
categories = ['Boys', 'Girls']
labels = ['2007', '2008', '2009', '2010', '2011', '2022', '2014']
data = [
    # Boys,  Girls
    [183.32, 190.52],
    [231.23,   5.0 ],
    [ 16.43,  53.1],
    [ 50.21,   7  ],
    [508.97,  10.45],
    [212.05,  20.2 ],
    [ 30.0,   20.0 ],
]
args = {
    'width': 50,
    'format': '{:<5.2f}',
    'suffix': '',
    'no_labels': False,
    'no_values': False,
    'color': None,
    'vertical': False,
    'stacked': False,
    'histogram': False,
    'bins': 5,
    'different_scale': False
}

colors = tg.check_data(labels, data, args)
if categories:
    tg.print_categories(categories, colors)
tg.chart(colors, data, args, labels)

hoge.py として保存して、実行してみる。

% python hoge.py
▇ Boys  ▇ Girls  


2007: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 183.32
      ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 190.52
2008: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 231.23
      ▏ 5.00 
2009: ▇ 16.43
      ▇▇▇▇▇ 53.10
2010: ▇▇▇▇ 50.21
      ▏ 7.00 
2011: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 508.97
      ▇ 10.45
2022: ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 212.05
      ▇ 20.20
2014: ▇▇ 30.00
      ▇ 20.00

% 

できあがり。

やっぱりお腹が痛くなる

この 2[buf] みたいな書き方もできちゃうというのが記憶の片隅にあり、true と印字されるんだろうなと思ったので最初の選択肢をポチッとした。 言語仕様で決まっているのかどうだったかまでは覚えてないけれども、とりあえずコンパイラがどんな機械語を生成するのか確認してみた。

環境

コンパイル&実行

やはり true が印字される。

[yusuke@albirex foo]$ gcc -g foo.c -o foo
[yusuke@albirex foo]$ file ./foo
./foo: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.32, BuildID[sha1]=12cde68dd82759dc3f81ce929093e43480f3c6f7, not stripped
[yusuke@albirex foo]$ ./foo
true
[yusuke@albirex foo]$ 

disas main

やはり 2[buf]buf[2] のように処理されている。。。

(gdb) disas main
Dump of assembler code for function main:
   0x000000000040052d <+0>:   push   %rbp
   0x000000000040052e <+1>:   mov    %rsp,%rbp
   0x0000000000400531 <+4>:   sub    $0x10,%rsp                 スタックを16バイト伸長する
   0x0000000000400535 <+8>:   movl   $0x33323032,-0x10(%rbp)    ベースポインタから -0x10 のところから "2" "0" "2" "3"
   0x000000000040053c <+15>:  movb   $0x0,-0xc(%rbp)            nullターミネート "\0"
   0x0000000000400540 <+19>:  movzbl -0x10(%rbp),%eax           buf[0] の値をバイト幅で eax へ
   0x0000000000400544 <+23>:  mov    %al,-0x1(%rbp)             eax の下位のバイトを rbp-1 へ
   0x0000000000400547 <+26>:  movzbl -0xe(%rbp),%eax            2[buf] の値をバイト幅で eax へ
   0x000000000040054b <+30>:  mov    %al,-0x2(%rbp)             eax の下位のバイトを rbp -2 へ
   0x000000000040054e <+33>:  movzbl -0x1(%rbp),%eax            rbp-1に取っておいた値をバイト幅で eax へ
   0x0000000000400552 <+37>:  cmp    -0x2(%rbp),%al             eax の下位バイトと rbp-2 の値を比較
   0x0000000000400555 <+40>:  sete   %al                        ...以下省略...
   0x0000000000400558 <+43>:  mov    %al,-0x3(%rbp)
   0x000000000040055b <+46>:  cmpb   $0x0,-0x3(%rbp)
   0x000000000040055f <+50>:  je     0x400568 <main+59>
   0x0000000000400561 <+52>:  mov    $0x400610,%eax
   0x0000000000400566 <+57>:  jmp    0x40056d <main+64>
   0x0000000000400568 <+59>:  mov    $0x400615,%eax
   0x000000000040056d <+64>:  mov    %rax,%rdi
   0x0000000000400570 <+67>:  callq  0x400410 <puts@plt>
   0x0000000000400575 <+72>:  mov    $0x0,%eax
   0x000000000040057a <+77>:  leaveq 
   0x000000000040057b <+78>:  retq   
End of assembler dump.
(gdb) 

メモリ上の配置

分かりにくいけど、こんな感じ。

        +------ rbp-0x10。つまり buf[0]
        | +---- rbp-0xe。 つまり buf[2] であり 2[buf]
        | | +-- rbp-0xc。 つまり buf[4]
        | | |           +-- rbp
        | | |           |        
        v v v           v
--------+-+-+-----------+-------
        2023n         22          // n は \0。2, 0, 2, 3 は、数値ではなく char。
--------+-------------+++-------
        |             ^^
        +-- rsp       ||
                      |+-- buf[0] からのコピー。つまり char c1
                      +--- 2[buf] からのコピー。つまり char c2

gcc オプション

ちなみに、-g をつけない場合も、main の disas は同じだった。-g はデバッグ情報を持つセクションを用意するだけで生成するマシン語には影響しないと思っておいてよさそう。 -O1 だとこんな感じ。もはや true を印字するのみ。

(gdb) disas main
Dump of assembler code for function main:
   0x000000000040052d <+0>:   sub    $0x8,%rsp
   0x0000000000400531 <+4>:   mov    $0x4005e0,%edi
   0x0000000000400536 <+9>:   callq  0x400410 <puts@plt>
   0x000000000040053b <+14>:  mov    $0x0,%eax
   0x0000000000400540 <+19>:  add    $0x8,%rsp
   0x0000000000400544 <+23>:  retq   
End of assembler dump.
(gdb) 
(gdb) x/s $rdi
0x4005e0:   "true"
(gdb) 

そして、gcc がどんなアセンブラを作るのかだけみたいのなら、gdb など使わずとも gcc -S foo.c とやれば良い。数値が 10 進数で表記されている。

[yusuke@albirex foo]$ gcc -S foo.c
[yusuke@albirex foo]$ cat foo.s
    .file   "foo.c"
    .section    .rodata
.LC0:
    .string "true"
.LC1:
    .string "false"
    .text
    .globl  main
    .type   main, @function
main:
.LFB0:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    subq    $16, %rsp
    movl    $858927154, -16(%rbp)
    movb    $0, -12(%rbp)
    movzbl  -16(%rbp), %eax
    movb    %al, -1(%rbp)
    movzbl  -14(%rbp), %eax
    movb    %al, -2(%rbp)
    movzbl  -1(%rbp), %eax
    cmpb    -2(%rbp), %al
    sete    %al
    movb    %al, -3(%rbp)
    cmpb    $0, -3(%rbp)
    je  .L2
    movl    $.LC0, %eax
    jmp .L3
.L2:
    movl    $.LC1, %eax
.L3:
    movq    %rax, %rdi
    call    puts
    movl    $0, %eax
    leave
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size   main, .-main
    .ident  "GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-44)"
    .section    .note.GNU-stack,"",@progbits
[yusuke@albirex foo]$ 

str.split() の maxsplit

maxsplit という引数があるのを知らなかったので、確認。

>>> 'a-b-c-d-e'.split('-', -5)
['a', 'b', 'c', 'd', 'e']
>>> 'a-b-c-d-e'.split('-', -4)
['a', 'b', 'c', 'd', 'e']
>>> 'a-b-c-d-e'.split('-', -3)
['a', 'b', 'c', 'd', 'e']
>>> 'a-b-c-d-e'.split('-', -2)
['a', 'b', 'c', 'd', 'e']
>>> 'a-b-c-d-e'.split('-', -1)
['a', 'b', 'c', 'd', 'e']
>>> 'a-b-c-d-e'.split('-', 0)
['a-b-c-d-e']
>>> 'a-b-c-d-e'.split('-', 1)
['a', 'b-c-d-e']
>>> 'a-b-c-d-e'.split('-', 2)
['a', 'b', 'c-d-e']
>>> 'a-b-c-d-e'.split('-', 3)
['a', 'b', 'c', 'd-e']
>>> 'a-b-c-d-e'.split('-', 4)
['a', 'b', 'c', 'd', 'e']
>>> 'a-b-c-d-e'.split('-', 5)
['a', 'b', 'c', 'd', 'e']
>>> 'a-b-c-d-e'.split('-', 6)
['a', 'b', 'c', 'd', 'e']
>>> 

バックグラウンドでコマンドを実行して、そのコマンドの stdout/stderr をファイルに記録する

以下のように複数のコマンドの出力を保存したかった。

  • コマンドを実行してその標準出力と標準エラー出力をファイルに書き出したい
  • 長い時間かかるコマンドがあるので、複数同時に実行したい

例えば、複数のホストに対する ping コマンドの出力とか。

とりあえず、こんな感じ。。。?

import subprocess


def do_pings(ip_addrs, count):
    procs = []
    for ip_addr in ip_addrs:
        p = subprocess.Popen(
            f'ping -c {count} {ip_addr}',
            shell=True,
            text=True,    # this "text" argument is valid since 3.7.
                          # For earlier releases, use
                          # universal_newlines=True, or
                          # encoding='utf-8'.
            stdout=open(f'ping_{ip_addr}_stdout.txt', 'w'),
            stderr=open(f'ping_{ip_addr}_stderr.txt', 'w'),
        )
        procs.append(p)
    [p.wait() for p in procs]

def main():
    IP_ADDRS = ('192.168.0.1', '192.168.0.2')
    PING_COUNT = 30
    do_pings(IP_ADDRS, PING_COUNT)

if __name__ == '__main__':
    main()

O'REILY 入門 Python 3 - 12.5 復習問題 12.9 の文字列

O'REILY 「入門 Python 3」の 12 章「データの自在な操作」にある復習課題 12.9 は、84 文字の文字列を入力しなければならない。 もう二度と 0 とか f を目で数えたくはないので、ここにメモしておくことにする。

'47494638396101000100800000000000ffffff21f9'
'0401000000002c000000000100010000020144003b'

www.oreilly.co.jp

asciitree の AttributeTraversal を使う

pypiasciitree というパッケージがある。 ターミナルにツリー表示を描くために使用することができるのだけど、README をパッと見る限りではツリー構造を dict で用意しなければならないようで、なんだか不便そう。ただ、ドキュメントを見ると children という属性を見てツリー構造を解釈してくれそうな AttributeTraversal というクラスがあるが、使用例がない。

というわけで、その使用例を書いてみる。書き出すツリーは、README と同じ以下のもの。

asciitree
 +-- sometimes
 |   +-- you
 +-- just
 |   +-- want
 |       +-- to
 |       +-- draw
 +-- trees
 +-- in
     +-- your
         +-- terminal

まずは、import。

from asciitree import LeftAligned
from asciitree.traversal import AttributeTraversal

そして、children を属性に持つ Node クラスを用意してみる。(とりあえず tuple にしているけど、list でも set でも良い。) この Node クラスのオブジェクトに対して str() が行われた結果が、ツリーの各ノードに表示される文字列になるので、__repr()__ でカスタマイズできる。

class Node:
    def __init__(self, data):
        self.__data = data
        self.children = tuple()

    def __repr__(self):
        return str(self.__data)

ノードを作っていく。

ast = Node('asciitree')
sts = Node('sometimes')
you = Node('you')
jst = Node('just')
wnt = Node('want')
to  = Node('to')
drw = Node('draw')
trs = Node('trees')
inn = Node('in')
yr  = Node('your')
tnl = Node('terminal')

各ノードが持つ子どもを tuple にまとめて、children に入れていく。(list とか set にする場合には、それに応じて適当に。)

ast.children = (sts, jst, trs, inn)
sts.children = (you,)
jst.children = (wnt,)
wnt.children = (to, drw)
inn.children = (yr,)
yr.children  = (tnl,)

traverse 引数に AttributeTraversal() を指定して LeftAligned のオブジェクトを作り、call する。

tr = LeftAligned(traverse=AttributeTraversal())
print(tr(ast))

これで、ツリー構造をターミナルに書くことができる。 dict で表現するよりはかなり楽。

ちなみに、asciitree はデフォルトで children という名前の属性を見てくれるのだけど、これを変更することも可能。AttributeTraversal オブジェクトを作る際に、attribute 引数で属性名を文字列で指定すれば良い。例えば、子どもがひとつしかない構造で属性名が child があるというような場合には、AttributeTraversal(attribute='child') と書けば良い。

tar の -a オプションを知らなかった話

macOS (BSD) の tar には -a (--auto-compress) というオプションがあるのを知った。

c (create) のときに指定できるオプションで -f に指定したアーカイブファイル名の拡張子部分によって、どの圧縮形式を使うのかを判断してくれる。

macOS の man tar にはこんな風に書いてある。

 -a, --auto-compress
         (c mode only) Use the archive suffix to decide a set of the for-
         mat and the compressions.  As a simple example,
               tar -a -cf archive.tgz source.c source.h
         creates a new archive with restricted pax format and gzip com-
         pression,
               tar -a -cf archive.tar.bz2.uu source.c source.h
         creates a new archive with restricted pax format and bzip2 com-
         pression and uuencode compression,
               tar -a -cf archive.zip source.c source.h
         creates a new archive with zip format,
               tar -a -jcf archive.tgz source.c source.h
         ignores the ``-j'' option, and creates a new archive with
         restricted pax format and gzip compression,
               tar -a -jcf archive.xxx source.c source.h
         if it is unknown suffix or no suffix, creates a new archive with
         restricted pax format and bzip2 compression.

.tar.bz2 にするために、 -j オプションを知らずに、

tar cf - aaa bbb ccc | bzip2 -c > xxx.tar.gz2

とか書いていた私。

それが、

tar acf xxx.tar.bz2 aaa bbb ccc

で済むというのは、とても楽だと思う。 というか、.zip も作れちゃうのね。tar さん。ステキ。

でも、parallel bzip2 (pbzip2) を使いたいときには、" --use-compress-program pbzip2" かな。

tar cf xxx.tar.bz2 --use-compress-program pbzip2 aaa bbb ccc