qtatsuの週報

初心者ですわぁ

pythonでJSON Linesを作る方法

前書き

Json Linesというのは以下のような形式です。

{"name":"reimu","score":1}
{"name":"marisa","score":1}

JSON Linesに解説されていますが

  1. UTF-8 Encodingであること
  2. 1行が1つのjsonオブジェクトであること
  3. 各行は\nで区切られていること
  4. (必須ではないが)拡張子はjsonlであること

そんなファイルです。 pysparkのデータ読み込みや、Elasticsearchのbulkでのデータ投入などで使ったりします。

このファイルをPythonを使って作成する方法を示しました。

参考リンク

環境

バージョン
MacOS Big Sur 11.1
Python3 3.9.1
pandas 1.1.4

Pythonのみを使う方法

以下のようなデータを用意します。

data_set =  [
 {'year': '2021', 'month': '01', 'day': '01', 'lang': 'Python', 'note': 'ぱいそん'},
 {'year': '2021', 'month': '01', 'day': '01', 'lang': 'Ruby', 'note': 'るびぃ'},
 {'year': '2021', 'month': '01', 'day': '01', 'lang': 'C++', 'note': 'しーぷらぷら'}
]

以下のように、リストの中身のオブジェクトを1行ずつ書き出します。

import json

with open('test.jsonl', mode='w', encoding='utf-8') as fout:
    for obj in data_set:
        json.dump(obj, fout, ensure_ascii=False)
        fout.write('\n')

上述の、JSON Linesのルールを守るために以下の設定を行っています。

  1. utf-8エンコーディングを確約するために、open関数でencodingを指定する。
  2. 1行が一つのjsonとなるように、for文を使いながら毎行、json.dumpを行っています。
  3. 各行を\nで区切っています。最終行の後にも付加されますが、それはどちらでもOKだとJSON Linesに明記されていました。
  4. 拡張子はjsonlにしています。

Pandasを使う方法

こちらの方が圧倒的に簡単です。

以下のようなデータを用意します(上の例と同じ)。

data_set =  [
 {'year': '2021', 'month': '01', 'day': '01', 'lang': 'Python', 'note': 'ぱいそん'},
 {'year': '2021', 'month': '01', 'day': '01', 'lang': 'Ruby', 'note': 'るびぃ'},
 {'year': '2021', 'month': '01', 'day': '01', 'lang': 'C++', 'note': 'しーぷらぷら'}
]

以下のように、DataFrameを作ります。

import pandas as pd

df = pd.DataFrame(data_set)
df
##    year month day    lang    note
## 0  2021    01  01  Python    ぱいそん
## 1  2021    01  01    Ruby     るびぃ
## 2  2021    01  01     C++  しーぷらぷら

書き出します。

df.to_json('test_pandas.jsonl', force_ascii=False, lines=True, orient='records')

force_asciiについては上述の通りです。 lines=Trueorient=recordsパラメータによって、jsonlの形式になります。このことは公式ドキュメント公式ドキュメントのlinesの部分に記述されています。

linesbool, default False If ‘orient’ is ‘records’ write out line delimited json format. Will throw ValueError if incorrect ‘orient’ since others are not list like.

lines=Trueがjsonを1行ずつ出力するjsonlフォーマットのオプションであり、このオプションを正しく動かすために、DataFrameのrowを行として扱うorient=recoresの設定が必要..というイメージだと思います。

出力したjsonlファイルのlint(壊れてないかチェック)

json.toolがコマンドラインインターフェイスを提供しています。利用しているPythonが3.8以上でしたら、jsonだけでなく、jsonlもlintできるオプション--json-linesが提供されています!

$ python3 -m json.tool --json-lines test.jsonl > /dev/null

jsonlの形式が壊れていたら教えてくれます。 /dev/nullは標準出力を捨てています。jsonlファイルの中身が表示されてしまうため、大きなファイルだと扱いづらいので指定しています。 (jsonの壊れている部分は標準エラーで出力されます)

結論

Pandasを利用できる環境ならPandasを使えば良いと思います。 そうでないならPythonjsonモジュールで頑張って書き出しましょう。

まとめ

もし、もっと簡単な方法などあればご教授いただけると嬉しいです! 間違いなどへのご指摘もいただけると幸いです。

【Python】f-stringとフォーマットの仕組みを特殊メソッド__format__から理解する。

前書き

先日、同僚の@JunyaFff、@takapdayon(それぞれtwitterアカウント)とPythonのfstringについての勉強会をしていた時の知見と、追加で調べたことを合わせてまとめました。

f-stringは知っているけど、パターンを覚えているだけで仕組みやFormat specificationsはよくわからない、という人向けの内容です。

参考リンク

環境

バージョン
MacOS Big Sur 11.1
Python3 3.9.1

f-stringの例と用語

f-stringはfを先頭につけた文字列リテラルです。文字列リテラル中では、波括弧{}によって、変数を置換することができます。

波括弧の部分は、置換フィールド(replacement field) と呼ばれているようです(参考リンク参照)。

>>> a = '1'
>>> print(f'-{a}-')
-1-

replacement field中で、!r!sまたは!aを変数の後ろに指定することで後述の__format__()をオーバーライドして変数の値を変換します。

この!から始まる型変換を、公式ドキュメントではconversion フィールド(conversion) と呼んでいるようです。

>>> a = '1'
>>> print(f'-{a!r}-')
-'1'-

# これと同じ。
>>> print(f'-{repr(a)}-')
-'1'-

またreplacement field中では:から始まるさまざまな記号を指定できます。下の例では「値を右寄せにしてスペース5つとなるよう0で埋める」という意味になります。 この:による書式の指定は、書式指定、書式指定文字列(format_spec、Format specifications) などと呼ばれているようです。

>>> print(f'-{a:0>5}-')
-00001-

f-stringで使う用語のまとめ

  • 置換フィールド(replacement field): {}の中身。
  • conversion フィールド(conversion): !sなど。
  • 書式指定、書式指定文字列(format_spec、Format specifications): :0>5など。

最強コマンド: help('FORMATTING')

string --- 一般的な文字列操作 — Python 3.9.2 ドキュメント こちらのドキュメントは、pythonのシェルに入り、help('FORMATTING')とすればいつでも見ることができます。 conversionやformat specificationsに指定できる値は、こちらからいつでも確認できて便利です。 特に、最後の方には例文がたくさん乗っていてそのまま使えます。

フォーマットの仕組みを考える

replacement fieldのフォーマットの仕組みを、例を挙げて考えてみます。

conversionフィールドは特殊メソッド__str__などを呼ぶ.

以下のクラスを定義します。

class MyClass:
    def __str__(self):
        return ('__str__')
    def __repr__(self):
        return ('__repr__')

このクラスをインスタンス化し、str(), repr()関数の引数にインスタンスを渡すと、特殊メソッド__str__(), __repr__()が呼ばれていることがわかります。

myclass = MyClass()

str(myclass)
#  '__str__'
repr(myclass)
#  '__repr__'

conversionの!s, !rでも、全く同じことをやっています!

print(f'-{myclass!s}-')
#  -__str__-
print(f'-{myclass!r}-')
#  -__repr__-

f-stringの置換フィールド中では、__format__()メソッドが呼ばれる。

先程のクラスのインスタンスですが、conversionを指定せずに呼ぶと以下のように、__str__()が呼ばれます この挙動がなぜ起こるのか、正確に考えてみます。

print(f'-{myclass}-')
#  -__str__-

まず、新しく__format__メソッドを導入したクラスを定義します。

class MyClassModFormatting:
    def __str__(self):
        return ('__str__')
    def __repr__(self):
        return ('__repr__')
    def __format__(self, format_spec):
        return ('__format__')

先ほどと同様にインスタンス化し、conversionをやってみます。 すると、f-stingの置換フィールドにインスタンスをおくと、__format__が呼ばれていることがわかります。

>>> myclass_mod = MyClassModFormatting()

>>> print(f'-{myclass_mod}-')
-__format__-

実は、f-sting中の置換フィールドでは、変数に対して組み込み関数format()を呼んでいるだけなのです。 そして組み込み関数format()は、対象のクラスの__format__()に全ての処理を丸投げする関数です。

format関数の定義は以下のようなものです。 PEP 3101 に定義が乗っています

def format(value, format_spec):
    return value.__format__(format_spec)

書式指定文字列(format_spec)の解釈は対象のクラス次第である。

format()関数、__fomrat__()メソッドは第二引数に書式指定文字列(format_spec)をとります。f-stringでは:から始まる記号です。 この書式指定文字列の解釈はクラスの__format__()の定義に完全に依存しています。換言すると、同じformat_specを指定しても、解釈は対象のクラスによって異なります。

以下のクラスは、与えられたformat_specをアットマークつきで返します。

class MyClassPritingSpec:
    def __format__(self, format_spec):
        return '__format__' + '@' + str(format_spec)

以下のようになります。f-stringのformat_spec(:以降の部分)をパースするのは、フォーマットされるクラスに定義された特殊メソッド__fomrat__()だということがわかります。

spec_printer = MyClassPritingSpec()
print(f'-{spec_printer:hoeeee}-')
#  -__format__@hoeeee-

datetime.datetimeオブジェクトは特殊な書式指定文字列を持つ

datetime.datetimeはstrftimeによってフォーマットされますが、これもformattingの仕組みを使っています。

以下の結果は全く同じです。dateimeクラスに定義された__format__()が、日付型専用の書式指定文字列をパースできるように定義されているのです。

>>> import datetime
>>> d = datetime.datetime(year=2000, month=3, day=3)

>>> d.strftime('%Y-%m')
'2000-03'

>>> f'{d:%Y-%m}'
'2000-03'

デフォルトの__format__()はobjectに定義されている

ここで最初の疑問に戻ります。__format__()を定義していないクラスMyClassのインスタンスは、f-stringにかけて出力すると以下のように__str__()が呼ばれるのでした。

print(f'-{myclass}-')
#  -__str__-

これは、自作クラスが継承しているobjectに定義された__format__()メソッドの挙動です。

PEP 3101 PEPとしてはここに記載されています。 引用すると、以下のようになっています。

class object:
    def __format__(self, format_spec):
        return format(str(self), format_spec)

引用終わり。

オブジェクトに対して、str()を呼んでからformatを呼んでいることがわかります。 ですので、先の例では-__str__-と出力されていたのでした。

再確認: conversion(!r)は__format__をオーバーライドする

最初の説明で、!r!sなどのconversionは「formatをオーバーライドする」と述べました。その実例をみていきます。

先程のMyClassModFormattingを再び使います。インスタンス化したあと、conversionを行います。

print(f'-{myclass_mod!s}-')
#  -__str__-
print(f'-{myclass_mod!r}-')
#  -__repr__-

結果、このクラスは自分で定義した__format__()が呼ばれていないということがわかります。__format__()の処理が、__str__()__repr__()で上書きされたのです。

ただし、!sや!rで変換した後のstringに対してはformatが呼ばれています。 書式指定文字列を渡さない場合、strはそのまま出力されるのでわかりにくいため、以下のようにしてみます。

>>> print(f'-{myclass_mod!s:0>30}-')
# -00000000000000000000000__str__-

このように、文字列に変換されたあとは「 str型に定義されている__format__ 」が呼ばれていました。

まとめ

f-stringはpython3.6から導入された便利な構文です。ただし、その処理の実体はPEP3101に記載があるformatの仕組みがそのまま使われています。(pythonのバージョンとしては3.0〜。これ以前は%によるフォーマットと、テンプレートシステムだけが存在していたようです。)

formatの仕組みは一見複雑に見えますが、基本的には3つの特殊メソッド__str__, __repr__, __format__で成り立っています。また、intかstrのformatに使う書式指定文字列を覚えていればよく、その例文はhelp('FORMATTING')でいつでもサッと確認できます。

科学計算の数値表現はもちろん、Web開発での文字列の組み立て、ログへの出力(!rを用いて型情報付きで出力するのが便利!)などなど、あらゆる場面で利用できます。仕組みを理解して上手に使っていきたいなと思います。

tmux + zsh環境でEmacsキーバインド [ ctrl + a ] や [ctrl + e]が効かない時の対処方法

問題

タイトルの通りです。 会社のMacをBig Surにアップグレードしたところ、なぜかターミナルでの作業でctrl+a(行の先頭に移動)や、ctrl+e(行末に移動)ができなくなってしまいました。これらのキーを押すと^A^Eが表示されてしまいます...

bashを使うとこの現象は起こりませんでした。どうやら、tmux + zshの時のみemacsキーバインドが解除されているようでした。

f:id:Qtatsu:20210129200733g:plain

修正

tmuxのセッション中でkeybindを確認すると、Aキーはそのまま入力されるようになっているようでした(self-insertと書かれていた)。

# tmux中で実行
% bindkey | grep A
"^A"-"^C" self-insert

一方、tmuxの外で同じようにkeybindを確認すると、行の先頭に移動するようになっていました。

% bindkey | grep A
"^A" beginning-of-line

調べてみると、これはctrl+eなども含め、以下の設定で「emacs風のkeybind」として一括で設定できるらしいので~/.zshrcに以下のように追記しました。

また注意点として、この記述は.zshrc内の、他のbindkeyコマンドより上に記述する必要があります! そうしないと、上流で設定したbindkeyコマンドはbindkey -eで上書きされて無効になってしまうようです。

  • .zshrc
bindkey -e    # emacsのキーバインド

あとは設定を読み込みます。

% source ~/.zshrc

無事、tmuxのセッション内部でもctrl+aで行頭に戻ることができるようになりました!

f:id:Qtatsu:20210129201841g:plain

環境

バージョン
MacOS Big Sur 11.1
tmux 3.1c
zsh 5.8 (x86_64-apple-darwin20.0)

参考リンク

本記事を書くにあたり、以下のリンクを参考にさせていただきました。

よく実行するコマンドにキーバインドを割り当てると捗る話 - Qiita

command line - CTRL-a and CTRL-e map incorrectly in tmux - Ask Ubuntu

【AWS CloudShell】tmuxを立ち上げた状態でタイムアウトするとアクセスできなくなる?

原因はわからないし解決もしていないのですが、ググっても同じ状態になっている人が見つからなかったのでインターネットの海に状況を共有するという意味で記事を書いておきます(後で消すか、原因がわかったら書き直します)

f:id:Qtatsu:20210123174314j:plain

以上のように、

Unable to start the environment. To retry, refresh the browser or restart by selecting Actions, Restart AWS CloudShell. System error: The XXXXXX is exceeding the monthly hours limit.

と表示され、リロードしても再起動しても同じメッセージが表示されて使えない状態になってしまいました。

まだ1時間程度しか使っておらず、最後にやったことといえばtmuxのsessionを立ち上げ、おやつを食べている間にタイムアウトになった...くらいです。

CloudShellは課金されないサービスで、特に困ってはいないので一旦様子見しようと思います。

2021/01/28追記

いつの間にか復活していました。AWS側の障害だったのでしょうか?

Pythonで個人用CLIツールを作成する

前書き

Pythonを使って、個人用CLIツールを作成するときの手順と参考になるリソースのまとめです。

最終的に、こんなイメージで使えるようにします。(例: noteというコマンドを作成)

# 仮想環境に入る
% source env/bin/activate

# 自作ツールのpip install
(env) % pip install --editable .  

# 自作ツールを使う
(env) % note "abc def efg"

GitHub

サンプルコードをgithubにアップロードしました。

GitHub - Kyutatsu-sandbox/python-cli-for-blog: sample code for blog post.

参考リンク

setup.pyを書いてパッケージ化する方法

How To Package Your Python Code — Python Packaging Tutorial

pythonのsetup.pyについてまとめる - Qiita

PythonでサクッとCLIツールを作る - Qiita

コマンドの引数を扱う標準ライブラリArgparse公式チュートリアル

Argparse チュートリアル — Python 3.9.1 ドキュメント

ターミナルで文字に色をつけるためのPythonライブラリ

termcolor · PyPI

環境

バージョン
MacOS Catalina 10.15.6
Python3 3.9.0

※ 古い方のMacを使ったのでCatalinaに戻ってます。誤植ではありません。

setup.pyを作成する

まずは仮想環境に入ります。

% python3.9 -m venv env
% source env/bin/activate

メインとなるpythonファイル(cli.py)とセットアップ用のファイルsetup.pyを作成します。

(env) % touch {cli,setup}.py

# このような構成になっていればOK
(env) % tree -L 1
.
├── cli.py
├── env
└── setup.py

まずcli.pyには、処理の入り口となる関数を定義します。コマンドを叩いたときに呼ばれる関数です。

from termcolor import colored, cprint


def main():
    print('STDOUT')
    return 'RETURN'

def color():
    cprint('STDOUT', 'green')
    text = colored('RETURN', 'red')
    return text

二つ関数を定義しました。color関数では、参考リンクに記述したライブラリtermcolorを利用しています。 ANSIカラー文字列を付加するwrapperです。ターミナル上に出力される文字列に色をつけることができます。


次にsetup.pyを書きます。

from setuptools import setup


setup(
        name='mycli-package',
        version='1.0.0',
        install_requires=['termcolor>=1.1.0'],
        entry_points={
            "console_scripts": ['mycli = cli:main', 'mycli_color = cli:color']
        }
)

とりあえず自分で作成したツールを使う上では上記の設定だけ分かっていれば事足りると思います。 詳しい説明は、参考リンクとして挙げた記事などを参照してください。

少し補足します。

install_requires

依存パッケージです。ここに書いておいたパッケージが、自作ツールをpipで入れるときに一緒にインストールされます。

package==1.0.0 (バージョン1.0.0を必ずいれる) のようなバージョン指定もできますし、単にpackageと言うふうに名称を書くだけでもOKです。 上記の例では1.1.0以上、としています。

entry_points

実際に、「コマンド(上例だとmycli, mycli_color)」と「実行される関数(上例だとmain, color)」をマッピングします。 console_scriptsというキーは決まっていて、CLIツールとして利用するコマンドをこのエントリーポイントへ書くことになっています(console_scripts Entry Point)。

pipでインストールする

ではコマンドを使ってみます。

まずは別の仮想環境を作成し、そちらに入ります。 (依存インストールなどがうまくいくことを確認するために行う手順です。ツール作成に必須ではありません)

% python3.9 -m venv another_env
% source another_env/bin/activate

# 出力なし。
(another_env) % pip freeze

先ほど作成したsetup.pyが存在するディレクトリへのパスを指定し、以下のようにしてインストールを行います。 (--editableについては後述)

# setup.pyがあるディレクトリ
(another_env) % pip install --editable  /Users/yourname/XXXX/python-cli-tool-template

pip freezeで確認します。 依存ライブラリとして設定したtermcolorも一緒にインストールされていることがわかります。

(another_env) % pip freeze
# Editable Git install with no remote (mycli-package==1.0.0)
-e /Users/yourname/XXXX/python-cli-tool-template
termcolor==1.1.0

ではコマンドを使ってみましょう!

f:id:Qtatsu:20210103155504g:plain
commnad-test

mycliコマンドで黒色の文字、mycli_colorで色付き文字がプリントされていることがわかります。

retrunした値は標準エラーに出力される

結果を見ると気が付くと思いますが、printで出力した文字だけでなく、returnした値も表示されています。

returnした値は標準エラー扱いです。

標準出力(1)を/dev/nullに捨てる、もしくは標準エラー(2)を捨てると以下のようになります。

(another_env) kyutatsu@Kmbp Temp % mycli 1> /dev/null
RETURN
(another_env) kyutatsu@Kmbp Temp % mycli 2> /dev/null
STDOUT

普通に使っているとどちらも表示されてしまいますが、以上のような挙動になっているので気をつけます。(詳しい理由と仕組みを知っている方がいれば教えていただけると幸いです)

--editableオプションをつけるとコードへの変更が即反映される

先ほどツールを入れるときに指定した--editableオプションですが、これは「develop mode」でのインストールになります。 ではdevelop modeとはなんぞやと言うことになりますが、順番に辿っていこうと思います。

実際の挙動

まずは挙動をみてみます。 先ほどインストールした自作ツールに変更を加えます。

def main():
    print('@@@@@@@@@@@@@STDOUT@@@@@@@@@@') 
    return 'RETURN'

保存してファイルを閉じ、mycliコマンドを打ってみます。

(another_env) % mycli
@@@@@@@@@@@@@STDOUT@@@@@@@@@@
RETURN

変更が反映されています。

では、--editableをつけなかった場合はどうなるのでしょうか。

まずcli.pyを元に戻します。

def main():
    print('STDOUT')
    return 'RETURN'

別の環境でインストールを行います。

# 別の環境
% python -m venv not_editable
% source not_editable/bin/activate

# 何もないことを確認
(not_editable) % pip freeze

MuduleNotFoundErrorを回避する

注意 Editableモードならこのままインストールすれば良いのですが、通常インストールではpackageやmoduleの場所を指定する必要があります。 今回は、setup.pyと同じディレクトリにモジュール(cli.py)を置いているので、以下のように追記します。このように書かないと、moduleを見つけることができずにエラーとなります。

  • setup.py
setup(
# .......(省略)..........
        py_modules = ['cli'],
# .......(省略)..........

ではインストールしていきます。

% pip install /User/yourname/XXXX/python-cli-tool-template

ここでfreezeしてみると、先ほどとは出力が違っていることがわかります! 自作ツールについていたはずのコメントなどや、editableモードであることを示す記述がなくなり、通常のライブラリと同じようにprintされています。

 % pip freeze
mycli-package==1.0.0
termcolor==1.1.0

今ここで、cli.pyを編集してみます。

def main():
    print('--------------STDOUT^---------------')
    return 'RETURN'

次にmycliコマンドを実行します。 先ほどと異なり、編集した内容が反映されません

反映されるには、パッケージを通常と同じように更新する必要があります

まずsetup.pyのバージョンもあげておきましょう。

  • setup.py
from setuptools import setup


setup(
        name='python-cli-tool-template',
        version='1.1.0',   # バージョンあっぷ!
# ....(省略).........
)

インストールします。

(not_editable)% pip install --upgrade  ~/MyProjects/GitHubForBlog/python-cli-tool-template

# 確認すると、バージョンが上がっているはず。
(not_editable) % pip freeze
python-cli-tool-template==1.1.0

挙動も更新されました!

(not_editable)  % mycli
--------------STDOUT^---------------
RETURN

editableモードの説明をドキュメントからたどる

まずはpipのヘルプをみてみます。

% pip install --help | grep -A 2 editable

  -e, --editable <path/url>   Install a project in editable mode (i.e.
                              setuptools "develop mode") from a local project
                              path or a VCS url.

(余談: grep-A 2は該当する行+後ろ2行も表示、と言う意味です)

「editable mode」は setuptoolsの「develop mode」を意味していることがわかります。

そこでsetuptools のドキュメントから該当箇所を探します。

Building and Distributing Packages with Setuptools — setuptools 51.1.1 documentation

Deploy your project in “development mode”, such that it’s available on sys.path, yet can still be edited directly from its source checkout.

ソースを直接変更できると言うことが書かれています。

さらに詳しい箇所がありました。 “Development Mode” — setuptools 51.1.1 documentation

It works very similarly to setup.py install, except that it doesn’t actually install anything. Instead, it creates a special .egg-link file in the deployment directory, that links to your project’s source code.

つまりeditableモードでinstallをした場合にはinstall先の仮想環境には何もインストールされず、直接ソースコードとコマンドがマップされるみたいですね。

Argparseでコマンドに引数を渡す

公式チュートリアルがあるので紹介程度に留めますが、作成したコマンドの引数を処理するには標準ライブラリArgparseが利用できます。

Argparse チュートリアル — Python 3.9.1 ドキュメント

  • 自動でヘルプ(-h)作成
  • 同時に使えない引数の設定や、可変長引数などの設定

など、sys.argsを直接パースしていては苦しい処理が楽にかけます。 上記チュートリアルを完了したら、以下のadd_argument関数のドキュメント部分を読めば大抵のことができると思います。

argparse --- コマンドラインオプション、引数、サブコマンドのパーサー — Python 3.9.1 ドキュメント

また、CLIツールを作成するフレームワークも存在します。本格的なCLIツールを作成したければそちらを使った方がいいかもしれません。 (自分は使ったことがないので、リンクの紹介のみです)

The Python Fire Guide - Python Fire

Welcome to Click — Click Documentation (7.x)

Pythonの組み込み関数zipに渡したイテレータはコピーされず、元のイテレータが消費される

前書き

タイトル通りの小ネタです。

この挙動を積極的に利用しているコードを散見するのですが、見るたびに混乱するので自分への戒めとしてまとめておきました。

参考リンク

Python公式zip()関数の説明

Built-in Functions — Python 3.9.1 documentation

環境

バージョン
MacOS Catalina 10.15.6
Python3 3.9.0

本題: 何が起こるのか?

タイトルの通り、「zipに渡したイテレータはコピーされず、元のイテレータが消費される」例を示します。

まずはイテレータを作成します。printすると、list_iterator objectがそれぞれ作成されたことを確認できます。

# イテレータの作成と確認
>>> str_it = iter(['a', 'b', 'c'])
>>> int_it = iter([1, 2, 3])

>>> print(str_it, int_it)
<list_iterator object at 0x114320910> <list_iterator object at 0x113e09d60>

これらのイテレータをzip関数に渡します。 zip関数自体も、イテレータを返します。nextを使って値を取り出してみると、zipに渡した二つのイテレータのそれぞれの最初の要素がタプルの形で取り出されていることがわかります。

>>> zipped = zip(str_it, int_it)

>>> next(zipped)
('a', 1)

このタイミングで、zip()関数に渡したstr_itint_itをnextしてみます。 すると、これらのイテレータを直接nextしたのはこれが初めてなのに、両方とも2番目の値が取得されました。

# 最初の要素 'a' ではなく、次の要素 'b' が返ってきた!
>>> next(str_it)
'b'

# 最初の要素 1 ではなく、次の要素 2 が返ってきた!
>>> next(int_it)
2

このままもう一度zippedをnextしてみましょう。 予想通り、先ほどstr_itint_itを一つ進めた影響が反映されているので、イテレータの最後の値が出力されます。

>>> next(zipped)
('c', 3)

zipにイテレータを渡した際の挙動を理解する

これまでにみてきた挙動は、単純に「渡したイテレータオブジェクトが、そのまま消費されている」だけなのですが、ドキュメントを見るとちょっと面白い事実がわかります。

Built-in Functions — Python 3.9.1 documentation

上記リンクから、zipの実装のイメージコード引用します(実際にはCで書かれているはずなので、あくまでイメージですが..)。

def zip(*iterables):
    # zip('ABCD', 'xy') --> Ax By
    sentinel = object()
    iterators = [iter(it) for it in iterables]
    while iterators:
        result = []
        for it in iterators:
            elem = next(it, sentinel)
            if elem is sentinel:
                return
            result.append(elem)
        yield tuple(result)

今回注目したいのは以下の部分です。

 iterators = [iter(it) for it in iterables]

可変長引数として渡されたiterablesについて、組み込みのiter関数を実行しています。 例えば、渡したiterablesがlistだった場合はこのタイミングでイテレータオブジェクトが作成されるだけです。ところが、イテレータが渡された場合に、iter関数は以下のように元のイテレータオブジェクトをそのまま返します。 (下のコード参照)

>>> it = iter([1,2,3])

# イテレータオブジェクトにiter()関数を使う。
>>> it_new = iter(it)

# 全く同じオブジェクト!!
>>> print(f"{id(it)} is {id(it_new)}")
4633907264 is 4633907264

以上の2点から、以下の仕組みがわかりました。

【AWS Glue】custom classifierを利用してログファイルからデータを抽出/変換する!【AWS CLI】

前書き

この記事は、JSL(日本システム技研) Advent Calendar 2020 - Qiita 12/17の記事です!

AWS GlueのクローラをClassifier(分類子)のgrokパターン定義とともに使ってApacheログを解析する手順を、AWS CLIを用いた実例とともに説明します。 AWS Glueを使えば、ログを直接確認するだけではなく、データの抽出や変換、統計量などをETLスクリプトとして記述することができます。

環境

AWS CLIの実行環境はCloud9です。

前提知識

実際の手順に入る前に、Classifier(分類子)の説明とCLIの使用例を説明します。

Classifier

分類子です。データの形式を定義します。クローラを動かす際、デフォルトでは対象のファイルの拡張子などからcsvjsonを認識してくれているようです(明示的に、すでに用意されているClassifierを使うこともできます)。 またcsvの区切り文字などを指定して、カスタムのclassifierを作ることもできます。

今回は、正規表現を使ってclassifierをカスタムできるgrokを使用します。

公式資料はこの辺です。 Writing Custom Classifiers - AWS Glue

GrokPatternでは、デフォルトで用意されたパターンを使ってデータを抜き取ることができます。 自分で正規表現を書いてパターンを記述することもできます。CustomPatternsにパターンの一覧を渡すことで、GrokPattern側で利用することができるようになります。

# 全classifierの一覧([]で階層潰し。*はgrok以外もとりたいので。)
aws glue get-classifiers --max-items 10 --query "Classifiers[*].*.[Name,Classification][]"

# 単品。取れる情報は↑と変わらないっぽい。
aws glue get-classifier --name custom-csv-classifier

# createの例。
aws glue create-classifier 
    --grok-classifier 'Classification=custom-tsv,Name=my-classifier,GrokPattern="%{NOTTAB:no}\\t%{NOTTAB:name}\\t%{NOTTAB:age}",CustomPatterns="NOTTAB [^\\t]*"'

# jsonで表記するcreateの例
cat << EOS > tmp-classifier-grok.json
{
  "Classification": "custom-tsv-special",
  "Name": "my-classifier",
  "GrokPattern": "%{NOTTAB:no}\\t%{NOTTAB:name}\\t%{NOTTAB:age}",
  "CustomPatterns": "NOTTAB [^\\t]*"
}
EOS
aws glue create-classifier --grok-classifier file://tmp-classifier-grok.json

# 削除
aws glue delete-classifier --name custom-csv-classifier
# update-classifierもあるが今回は省略。

前準備(データの投入)

Athenaのドキュメントの例を今回は使わせていただくことにします。(grokの書きかたもこちらの通り。)

Querying Apache Logs Stored in Amazon S3 - Amazon Athena こちらのページから、以下のファイルをコピーして、S3の適当なパスにアップロードしておきます。

S3_BUCKET_NAME='your_bucket_name'

cat << EOS > access.log
198.51.100.7 - Li [10/Oct/2019:13:55:36 -0700] "GET /logo.gif HTTP/1.0" 200 232
198.51.100.14 - Jorge [24/Nov/2019:10:49:52 -0700] "GET /index.html HTTP/1.1" 200 2165
198.51.100.22 - Mateo [27/Dec/2019:11:38:12 -0700] "GET /about.html HTTP/1.1" 200 1287
198.51.100.9 - Nikki [11/Jan/2020:11:40:11 -0700] "GET /image.png HTTP/1.1" 404 230
198.51.100.2 - Ana [15/Feb/2019:10:12:22 -0700] "GET /favicon.ico HTTP/1.1" 404 30
198.51.100.13 - Saanvi [14/Mar/2019:11:40:33 -0700] "GET /intro.html HTTP/1.1" 200 1608
198.51.100.11 - Xiulan [22/Apr/2019:10:51:34 -0700] "GET /group/index.html HTTP/1.1" 200 1344
EOS

aws s3 cp access.log  "s3://${S3_BUCKET_NAME}/log-resource-sample/access.log"

実際の手順

Databaseの作成

  • この後の手順でクローラを起動し、テーブルを作成を行います。そのテーブルを保持するデータベースを作成しておきます。(これまでにつくったデータベースがあれば、それを利用してもOKです。)
cat << EOS > database-definition.json
{
  "Name": "access-log-db",
  "Description": "analyze access logs."
}
EOS
aws glue create-database --database-input file://database-definition.json


# 完了確認
aws glue get-database --name access-log-db

Classifierの作成

ログ解析に使うgrokのパターンも、Athenaのドキュメントで説明されている物をそのまま使用させていただきます。 Querying Apache Logs Stored in Amazon S3 - Amazon Athena

cat << EOS > tmp-classifier-grok.json
{
  "Classification": "custom-log",
  "Name": "access-log-classifier",
  "GrokPattern": "^%{IPV4:client_ip} %{DATA:client_id} %{USERNAME:user_id} %{GREEDYDATA:request_received_time} %{QUOTEDSTRING:client_request} %{DATA:server_status} %{DATA: returned_obj_size}$"
}
EOS
aws glue create-classifier --grok-classifier file://tmp-classifier-grok.json
aws glue get-classifier --name access-log-classifier

crawlerの作成

  • ロールの作成は省略します。
  • こちらの記事で作成したAWSGlueServiceRole-Tmp-Testロールと同じロールを作成すればOKです。
    • この後指定するS3バケットへのアクセス権限を付与しておきます。リンク先の記事を参照してください。

これまでの手順で作成したDatabaseとClassifierおよびロールに加え、クロール対象のS3パスを指定します. クロール対象を指定するjsonの書き方は、以下のようにして調べることができます。

# 1. ヘルプを開く
aws glue create-crawler help

# 2. cli-skeletonコマンドを叩く。(こちらの詳しい説明は省略します)
aws glue create-crawler --generate-cli-skeleton

それでは実際の手順を進めていきます。

CRAWLER_ROLE=AWSGlueServiceRole-Tmp-Test
DATABASE_NAME=access-log-db
CLASSIFIER=access-log-classifier
S3_BUCKET_NAME='your_bucket_name'

# クロールターゲット(s3)の指定を示すjsonは少々長いため、一度ファイルに書き込む。
# 「前準備」の項目で作成したファイルへのパスを記載する。
cat << EOS > tmp.json
{
    "S3Targets": [
        {
            "Path": "s3://${S3_BUCKET_NAME}/log-resource-sample",
            "Exclusions": []
        }
    ],
    "JdbcTargets": [],
    "DynamoDBTargets": [],
    "CatalogTargets": []
}
EOS

# jsonが壊れていないか確認しておく。
python3 -m json.tool tmp.json

クローラを作成します。

AWS Tags in AWS Glue - AWS Glue 必須ではありませんがこの時タグをつけておくと良いです。 今回は詳しい説明は省きますが、タグをつけておくと後々検索や使用した料金などの分析、権限管理に役立ちます。 タグの形式はKey=Value,Key2=Value2というものであり、helpから確認することができます。

aws glue create-crawler \
    --name grok-accesslog-crawler \
    --role $CRAWLER_ROLE \
    --database-name $DATABASE_NAME \
    --description "character csv crawler" \
    --classifiers $CLASSIFIER\
    --targets file://tmp.json \
    --tags Name=accesslog,Creation=cli

完了確認にはlist-crawlersコマンドが便利です。 このコマンドはcrawlerの名前だけをlistするので、出力が少なくすっきりしています。

# queryで、クローラにつけた名前の一部を利用して出力を絞るパターン
aws glue list-crawlers --query "CrawlerNames[?contains(@, 'accesslog')]"
[
    "grok-accesslog-crawler"
]

タグをつけていれば、それを使った絞り込みもできます。一連のリソースに同じタグをつけておけば便利ですね。

# タグを使って出力を絞る例。
aws glue list-crawlers --tags Name=accesslog
{
    "CrawlerNames": [
        "grok-accesslog-crawler"
    ]
}

crawlerの情報確認

クローラの実行前に、クローラの詳しい情報を出力するコマンドを見ておきます。

# 特定のcrawlerの情報を出力する。
aws glue get-crawler --name grok-accesslog-crawler

特に、実行中のステータスをfilterして確認できるようにしておきます。

aws glue get-crawler --name grok-accesslog-crawler --query "Crawler.{NAME: Name, STATE: State, TIME: CrawlElapsedTime}"
{
    "NAME": "grok-accesslog-crawler",
    "STATE": "READY",
    "TIME": 0
}

こんな感じですかね。 READYは実行準備OK、つまり動いていない状態です。 一応クローラ実行も僅かながら課金されるので、実行時間も表示しておきます。


ではクローラを実行していきます。

# dbにある現在のテーブルを確認。
$ aws glue get-tables --database-name gensou-db --query "TableList[*].Name"

クローラの実行

# dbにある現在のテーブルを確認。(まだ何もないはず)
DATABASE_NAME=access-log-db
aws glue get-tables --database-name $DATABASE_NAME --query "TableList[*].Name"
[]

# クローラ実行
aws glue start-crawler --name grok-accesslog-crawler

実行直後にクローラの情報をみると、RUNNINGとなります。

aws glue get-crawler --name grok-accesslog-crawler --query "Crawler.{NAME: Name, STATE: State, TIME: CrawlElapsedTime}"
{
    "NAME": "grok-accesslog-crawler",
    "STATE": "RUNNING",
    "TIME": 6793
}

しばらくするとSTOPPINGになります。 TIMEは51000ms...だと思います(単位違ってたら申し訳なし)

{
    "NAME": "grok-accesslog-crawler",
    "STATE": "STOPPING",
    "TIME": 51000
}

READYに戻れば完了です!

{
    "NAME": "grok-accesslog-crawler",
    "STATE": "READY",
    "TIME": 0
}

この時点でDatabaseの中身をみると、テーブルが作成されているはずです。 get-tablesでテーブルの存在を確認し、get-tableコマンドで詳しく中身を見てみます。

まずは新しく作成されたテーブル名を確認します。

DATABASE_NAME=access-log-db
aws glue get-tables --database-name $DATABASE_NAME --query "TableList[*].Name"
[
    "log_resource_sample"
]

詳しくテーブルを見ていきます。 興味があるのはカラム定義と、自分がClassifierを定義したときのgrokパターンの比較です。

以下のようにqueryを使って出力を絞ると、定義したgrokパターンにしたがったテーブル定義となっていることを確認しやすくなります。

# 引っかかった名前で詳しく見る。
aws glue get-table --database-name $DATABASE_NAME --name log_resource_sample \
    --query "Table.{MAPPING: StorageDescriptor.Columns, GROK: Parameters.grokPattern}"                                                                
{
    "MAPPING": [
        {
            "Name": "client_ip",
            "Type": "string"
        },
        {
            "Name": "client_id",
            "Type": "string"
        },
        {
            "Name": "user_id",
            "Type": "string"
        },
        {
            "Name": "request_received_time",
            "Type": "string"
        },
        {
            "Name": "client_request",
            "Type": "string"
        },
        {
            "Name": "server_status",
            "Type": "string"
        },
        {
            "Name": " returned_obj_size",
            "Type": "string"
        }
    ],
    "GROK": "^%{IPV4:client_ip} %{DATA:client_id} %{USERNAME:user_id} %{GREEDYDATA:request_received_time} %{QUOTEDSTRING:client_request} %{DATA:server_status} %{DATA: returned_obj_size}$"
}

ジョブの作成 & 実行

テーブルを作成することができたので、これでAthenaを使ってクエリを投げることもできます。

f:id:Qtatsu:20201218011700p:plain

今回は「ログを解析に使う」ことを想定した手順となっています。ですので、統計処理やデータの変換を行うことを想定し、Glueのジョブを使った変換を行ってみます。

今回作るジョブはサンプルなので、「抽出したデータをjsonに変換する」というだけの処理を実行しようと思います。

ジョブスクリプトの作成

以下のスクリプトを作成します。以下の一文の、s3バケットに注意してください。自分のS3バケット名を指定しないとエラーとなってしまいますdatasink2 = glueContext.write_dynamic_frame.from_options(frame = applymapping1, connection_type = "s3", connection_options = {"path": "s3://your-bucket-name/log-output"}, format = "json", transformation_ctx = "datasink2")

以下のコードをjob-for-accesslog-convertion.pyというファイルをに記述して保存します(カレントディレクトリに置いてください)。

import sys

from awsglue.transforms import *
from awsglue.utils import getResolvedOptions
from pyspark.context import SparkContext
from awsglue.context import GlueContext
from awsglue.job import Job

## @params: [JOB_NAME]
args = getResolvedOptions(sys.argv, ['JOB_NAME'])

sc = SparkContext()
glueContext = GlueContext(sc)
spark = glueContext.spark_session
job = Job(glueContext)
job.init(args['JOB_NAME'], args)


datasource0 = glueContext.create_dynamic_frame.from_catalog(database = "access-log-db", table_name = "log_resource_sample", transformation_ctx = "datasource0")

applymapping1 = ApplyMapping.apply(frame = datasource0, mappings = [("client_ip", "string", "client_ip", "string"), ("client_id", "string", "client_id", "string"), ("user_id", "string", "user_id", "string"), ("request_received_time", "string", "request_received_time", "string"), ("client_request", "string", "client_request", "string"), ("server_status", "string", "server_status", "string"), (" returned_obj_size", "string", " returned_obj_size", "string")], transformation_ctx = "applymapping1")


datasink2 = glueContext.write_dynamic_frame.from_options(frame = applymapping1,  connection_type = "s3", connection_options = {"path": "s3://your-bucket-name/log-output"}, format = "json", transformation_ctx = "datasink2")
job.commit()

ファイルは、ジョブがアクセス可能なS3のバケットにアップロードしておきます。

S3_BUCKET_NAME='your_bucket_name'
S3_BUCKET_PREFIX='job-files'

aws s3 cp job-for-accesslog-convertion.py "s3://${S3_BUCKET_NAME}/${S3_BUCKET_PREFIX}/job-for-accesslog-convertion.py"

ジョブの作成と確認

ロールはクローラと同じ物を流用します。 parquetの吐き出し先S3への書き込み権限があることには注意しておきます。

JOB_NAME=grok-access-log-test-1217
JOB_ROLE=AWSGlueServiceRole-Tmp-Test
S3_BUCKET_NAME='your_bucket_name'
S3_BUCKET_PREFIX='job-files'

# 作成前に、ジョブがないことを確認。
aws glue list-jobs --tags Name=accesslog

# ジョブの作成
aws glue create-job --name $JOB_NAME \
    --role $JOB_ROLE --glue-version "2.0" \
    --number-of-workers 2 --worker-type "Standard" \
    --command "Name=glueetl,ScriptLocation=s3://${S3_BUCKET_NAME}/${S3_BUCKET_PREFIX}/job-for-accesslog-convertion.py,PythonVersion=3"  \
    --tags Name=accesslog,Creation=cli
    
# 完了確認。
aws glue list-jobs --tags Name=accesslog
{
    "JobNames": [
        "grok-access-log-test-1217"
    ]
}

# ジョブの情報を見てみる。
aws glue get-job --job-name $JOB_NAME

# ジョブを削除する場合。
# aws glue delete-job --job-name grok-access-log-test-1217

https://docs.aws.amazon.com/glue/latest/dg/add-job.html

For AWS Glue version 2.0 jobs, you cannot instead specify a Maximum capacity. Instead, you should specify a Worker type and the Number of workers. For more information,....

--max-capacityというオプションはglue 2.0では使わず、Worker type and the Number of workersを指定すべきっぽいのでそのようにしました。 詳細はhelpを見れば詳しく書いてあります。

デフォルトの設定でジョブを作成すると、最小構成ではなくなるためジョブ実行コストが上がります。練習用にジョブを実行するときには、なるべく小さなリソースを割り当てると良いと思います。

リソースが足りなければ、ジョブの実行時にも指定して上書きできるため、ジョブ作成時には最小にしておけば問題ありません。

実際に最低限必要なオプションは、ジョブ名を決める--nameとジョブの種類とスクリプトの置き場所を決める--commandのみです。

ジョブの実行

aws glue start-job-run --job-name $JOB_NAME

S3の出力先を確認すればJSONファイルができているはずです。 この出力先S3ディレクトリをクロールして、新たにテーブルを作成すればAthenaでクエリをつかって中身をみることもできます。

今回はJSONファイルに変換しただけなのでETLスクリプトの恩恵は得られませんが、データの加工やS3以外のデータストアにデータを流すこともできるため、いろいろ試せそうです。またログファイル以外でも、grokパターンを使えばどんなパターンで書かれたファイルも解析して読み込むことができます。