Python: あるかどうかわからないメソッドを呼び出す

 んー、タイトルが何をいってるのかよくわからないけれども。

先日使った Lark の lark.Transformer や lark.Interpreter というクラスでは、構文木をたどりながら文法要素の名前をもつメソッドを呼び出していた。「文法要素の名前」はユーザが定義するので、ライブラリである Lark が予め知っているはずがない。でもちゃんと動いていた。ということは何らかの方法があるわけで、Ruby だったら Object#send を使えばいいんだけど Python ではどうやるんだろう、というわけで調べてみた。

こたえ: getattr() でクラスの持つメソッド(存在すれば)を取得できる

試しにこんなクラスを書いた。

>>> class User:
...     def name(self):
...         return 'Andy'
...     def age(self):
...         return 32
...     def info(self, key):
...         f = getattr(self, key)
...         return f()         

試してみよう。

>>> andy = User()
>>> andy.name()
'Andy'
>>> andy.age()
32         

ここまでは普通のメソッド呼び出し。次の User.info() が getattr() を使っている。

>>> andy.info('name')
'Andy'
>>> andy.info('age')
32         

存在しないメソッド名を指定すると AttributeError になる。

>>> andy.info('job')
Traceback (most recent call last):
  File "<stdin>", line 1, in 
  File "<stdin>", line 7, in info
AttributeError: 'User' object has no attribute 'job'         

なるほど。あとは必要に応じて AttributeError の処理をすればいいわけだな。理解した。

Python: Larkを使って構文解析器をつくってみた(その2)

こないだつくった構文解析器をちょっと改良、というか修正して、入力データをコマンド形式にしてみた。

コマンド形式、っていうのは、入力データ(の内部表現)を作るための一種の DSL だ。代入も制御構造もないシェルスクリプトみたいなものと考えてくれればいい。コマンド形式にしておけば、あとから機能追加によって入力データの項目が増えたとしても対応するコマンドを追加するだけ済み、パーサ(構文解析器)を修正する必要がない。

入力データの見た目はほとんど変わらなくて、* がなくなっただけ。これは、こないだのはデータの種類を表すラベルだったのに対して、今回のはコマンド名を表すからそれらしくした。

// Example for input data.
MODEL
  RO
GAMMA0.5  // %
  0.150
HMAX
  0.200
PLOT      // %
  0.0001
  0.0002
  0.0005
  0.001
  0.002
  0.005
  0.01
  0.02
  0.05
  0.1
  0.2
  0.5
  1.0
  2.0
  5.0
 10.0
END

各コマンドは、コマンド名(行の先頭から始まる必要がある)と 0 個以上の引数からなり、改行で終わる。空白文字で始まる行は前の行の続きとみなすのと// から行末までをコメントとするのもこないだと同じ。

文法定義ファイルはこうなった。

?script : statement+

statement : command arg* "\n"

command : /[A-Z0-9.]+/

arg : string
    | real

string : UCASE_LETTER+

real : FLOAT

%import common.UCASE_LETTER
%import common.FLOAT
%import common.WS_INLINE
%ignore WS_INLINE

コメントや継続行の処理も文法でできるようだけど、今回はパースする前の前処理でやってしまうことにした。そのほうが文法が楽だったから。

さて、Lark には lark.Interpreter というクラスが用意されている。このクラスの visit() メソッドを使うと、パースした木構造を他のデータ構造に変換する代わりに、ノードをたどりながら処理を実行できる。今回はコマンド名と引数リストを出力してるだけだけど、これでデータを構築する処理を書けばいいわけだ。

from lark import Lark
from lark.visitors import Interpreter
import sys
import re
from pprint import pprint


class MyInterpreter(Interpreter):
    def script(self, tree):
        print("SCRIPT")
        for c in tree.children:
            self.visit(c)

    def statement(self, tree):
        cmd = self.visit(tree.children[0])
        args = [ self.visit(a) for a in tree.children[1:] ]
        print("  STATEMENT")
        print("    COMMAND: " + cmd)
        print("    ARGS: " + str(args))

    def command(self, tree):
        return tree.children[0]

    def arg(self, tree):
        return self.visit(tree.children[0])

    def string(self, tree):
        return "".join(tree.children)

    def real(self, tree):
        return float(tree.children[0].value)


def contract(text):
    lines = text.splitlines(keepends=False)
    lines_new = []
    for line in lines:
        line = re.sub(r"//.*$", "", line)
        if len(line) == 0:
            pass
        elif line.startswith(" "):
            lines_new[-1] += line
        else:
            lines_new.append(line)
    return "\n".join(lines_new) + "\n"


with open("larksample2.lark", "r", encoding="utf-8") as grammar:
    parser = Lark(grammar.read(), start="script")

with open(sys.argv[1], "r") as f:
    input_data = contract(f.read())

tree = parser.parse(input_data)
MyInterpreter().visit(tree)

実行するとこうなった。

takatoh@apostrophe:larksample$ python larksample2.py larksample2.dat
 SCRIPT
   STATEMENT
     COMMAND: MODEL
     ARGS: ['RO']
   STATEMENT
     COMMAND: GAMMA0.5
     ARGS: [0.15]
   STATEMENT
     COMMAND: HMAX
     ARGS: [0.2]
   STATEMENT
     COMMAND: PLOT
     ARGS: [0.0001, 0.0002, 0.0005, 0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0]
   STATEMENT
     COMMAND: END
     ARGS: []

Python: Larkを使って構文解析器をつくってみた

構文解析器を「つくってみた」というと、四則演算や簡単なプログラミング言語をつくってみたというのをよく見かけるけど、個人的には何かのプログラムの入力データをパース(構文解析)することが多い。

四則演算ができたところで結局は「つくってみた」以上のものではないし、プログラミング言語を(本格的に)つくってみようという人もどちらかといえば稀だろう。

一方でプログラム(それが何であれ)というのはたいてい入力が必要なものだし、ファイルから入力を読み込むことも少なくない。ただデータが並んでいればいい、というのでなければ何らかのフォーマットを決める必要がある。JSON とか YAML とかいった汎用のフォーマットもあるけど、必ずしも目的に対して適当とは言えないこともある。

というわけで構文解析器(パーサ)の出番になるわけだ。

以前には Haskel のパーサコンビネータ(Parsec)や Ruby のパーサジェネレータ(Racc)を使ったことがあるけど、Python でははじめて。かるく調べてみたところ、タイトルに書いた Lark というライブラリが良さげだったので試してみることにした。

今回はずいぶん前に Haskell でつくったあるプログラムの入力ファイルをサンプルとしてとりあげる。↓こんなデータ。

// Example for input data.
*MODEL
  RO
*GAMMA0.5  // %
  0.150
*HMAX
  0.200
*PLOT      // %
  0.0001
  0.0002
  0.0005
  0.001
  0.002
  0.005
  0.01
  0.02
  0.05
  0.1
  0.2
  0.5
  1.0
  2.0
  5.0
 10.0
*END

* で始まる文字列がデータの種類を表すラベルで、その後に1つ以上のデータが空白で区切られて続く。改行は意味を持たない(空白とみなす)。ただし // から行末まではコメント。

さて、Lark ではまず解析対象の文法を定義したファイルを用意する。文法は EBNF ベースの構文で定義する。今回の例ではこうなった。

?input : model gamma hmax plot end

model : "*MODEL" modelname

modelname : UCASE_LETTER+

gamma : "*GAMMA0.5" real

hmax : "*HMAX" real

plot : "*PLOT" real+

end : "*END"

real : FLOAT

%import common.UCASE_LETTER
%import common.FLOAT
%import common.WS
%ignore WS
%import common.CPP_COMMENT
%ignore CPP_COMMENT

で、これを Lark に渡せばパーサを返してくれる。プログラムはこう。

from lark import Lark
import sys


with open("larksample.lark", "r", encoding="utf-8") as grammar:
    parser = Lark(grammar.read(), start="input")

with open(sys.argv[1], "r") as f:
    input_data = f.read()

tree = parser.parse(input_data)
print(tree)

たったこれだけで構文解析をして構文木を返してくれる。実行してみると:

takatoh@apostrophe:larksample$ python larksample.py larksample.dat
 Tree('input', [Tree('model', [Tree('modelname', [Token('UCASE_LETTER', 'R'), Token('UCASE_LETTER', 'O')])]), Tree('gamma', [Tree('real', [Token('FLOAT', '0.150')])]), Tree('hmax', [Tree('real', [Token('FLOAT', '0.200')])]), Tree('plot', [Tree('real', [Token('FLOAT', '0.0001')]), Tree('real', [Token('FLOAT', '0.0002')]), Tree('real', [Token('FLOAT', '0.0005')]), Tree('real', [Token('FLOAT', '0.001')]), Tree('real', [Token('FLOAT', '0.002')]), Tree('real', [Token('FLOAT', '0.005')]), Tree('real', [Token('FLOAT', '0.01')]), Tree('real', [Token('FLOAT', '0.02')]), Tree('real', [Token('FLOAT', '0.05')]), Tree('real', [Token('FLOAT', '0.1')]), Tree('real', [Token('FLOAT', '0.2')]), Tree('real', [Token('FLOAT', '0.5')]), Tree('real', [Token('FLOAT', '1.0')]), Tree('real', [Token('FLOAT', '2.0')]), Tree('real', [Token('FLOAT', '5.0')]), Tree('real', [Token('FLOAT', '10.0')])]), Tree('end', [])])

パースした結果が木構造になってるのがなんとなくわかる。とはいえさすがにこれは見づらいので、Tree クラスに用意されてる pretty() メソッドを使ってみよう。print(tree) を print(tree.pretty()) に変えてやればいい。

takatoh@apostrophe:larksample$ python larksample.py larksample.dat
 input
   model
     modelname
       R
       O
   gamma
     real    0.150
   hmax
     real    0.200
   plot
     real    0.0001
     real    0.0002
     real    0.0005
     real    0.001
     real    0.002
     real    0.005
     real    0.01
     real    0.02
     real    0.05
     real    0.1
     real    0.2
     real    0.5
     real    1.0
     real    2.0
     real    5.0
     real    10.0
   end

見やすくなったね。

この木構造をプログラムで利用しやすい形に変換するには lark.Transformer クラスを継承したクラスをつくってやる。プログラムはこうなった。

from lark import Lark
from lark import Transformer
import sys
from pprint import pprint


class MyTransformer(Transformer):
    def input(self, tokens):
        (model, gamma, hmax, plot, _) = tokens
        return {
            "model" : model,
            "gamma0.5" : gamma,
            "hmax" : hmax,
            "plot" : plot,
        }

    def model(self, tokens):
        (m,) = tokens
        return m

    def modelname(self, tokens):
        return "".join(tokens)

    def gamma(self, tokens):
        (g,) = tokens
        return g

    def hmax(self, tokens):
        (h,) = tokens
        return h

    def plot(self, tokens):
        return list(tokens)

    def end(self, tokens):
        return None

    def real(self, tokens):
        (r,) = tokens
        return float(r)


with open("larksample.lark", "r", encoding="utf-8") as grammar:
    parser = Lark(grammar.read(), start="input")

with open(sys.argv[1], "r") as f:
    input_data = f.read()

tree = parser.parse(input_data)
result = MyTransformer().transform(tree)
pprint(result)

lark.Transformer を継承した MyTransformer クラスの transform() メッソッドにパースした結果を渡してやると、変換した結果が返ってくる。今回は普通の辞書にした。

MyTransformer クラスに定義しているメソッドは、それぞれ文法ファイルで定義した構文要素に対応していて、transform() は構文木をたどりながらノード(つまり構文要素)に対応するメソッドを呼び出す。各メソッドの引数 tokens は子ノードのリスト。だからこれらのメソッドから適切な値を返せば、最終的に目的の構造を持ったデータ(今回は辞書)が手に入る。

これを実行するとこんなふうに出力される。

takatoh@apostrophe:larksample$ python larksample.py larksample.dat
 {'gamma0.5': 0.15,
  'hmax': 0.2,
  'model': 'RO',
  'plot': [0.0001,
           0.0002,
           0.0005,
           0.001,
           0.002,
           0.005,
           0.01,
           0.02,
           0.05,
           0.1,
           0.2,
           0.5,
           1.0,
           2.0,
           5.0,
           10.0]}

無事、目的のものが手に入った。

[追記]

インストール:

takatoh@apostrophe:larksample$ pip install lark-parser

参考ページ:

その他:

  • PyPI には lark てのと lark-parser てのがあるけどどう違うの?
  • lark.Transformer.transform() から呼び出される各メソッドは、子要素(を評価した値)のリスト tokens を引数にとり、自身を評価した値を返す。
  • 子要素とはつまり、文法ファイルで定義した : の右側の要素のこと。
  • ではあるんだけど、文法ファイルで文字列を直接使う(たとえば "*MODEL" のように)と、メソッドの引数リストに入らない。これ、気づくまでにちょっとかかった。
  • よく使いそうな文法要素(FLOAT とか UCASE_LETTER とか)は予め定義されてて、インポートして使うことができる。ドキュメントには全部は書いてないみたいなので GitHub のリポジトリでソースファイルを見るといい。

PythonスクリプトをパッケージングしてPyPIに公開するまでのあれこれ

このあいだ作った、画像ファイルから EPUB ファイルをつくるスクリプトを、wheel でパッケージングして PyPI に公開した。バージョンは v0.3.0。ソースは GitHub に上げてある。

ファイル構成は変わったし、実行コマンドに click を導入して img2epub build コマンドで EPUB を生成するようにしたけど、機能的にはこのあいだのスクリプトと大して変わってないので改めては説明しない。必要なら上のリンク先を見てほしい。

代わりに、Python でパッケージを開発して PyPI に公開するまでの作業で、これまで知らなかったりあやふやだったところを調べたのでその辺についてメモしておく。以前とはやり方が変わったりしてて、知識はアップデートしなきゃならんと思った。

環境

  • Windows 10 Pro
  • PowerShell 7
  • Python 3.8.6

venv

開発用の仮想環境。以前は virtualenv というサードパーティのパッケージを使ってたけど、Python 3.3 から venv というのが標準でついてくる。プロジェクトのディレクトリで次のようにすると仮想環境が作られる。

takatoh@montana: myproject > python -m venv .venv

.venv とういのが仮想環境の名前。なんでもいいようだけど .venv とすることが多いみたい。仮想環境をアクティベートするには:

takatoh@montana: myproject > .venv/Scripts/Activate.ps1
(.venv) takatoh@montana: myproject >

アクティベートされると2行目のようにプロンプトに仮想環境名が表示される。仮想環境から抜けるにはこう:

(.venv) takatoh@montana: myproject > deactivate

作ったばかりの仮想環境には pip と setuptools しか入ってない。

(.venv) takatoh@montana: myproject > pip list
Package    Version
---------- -------
pip        20.2.1
setuptools 49.2.1
WARNING: You are using pip version 20.2.1; however, version 20.2.4 is available.
You should consider upgrading via the 'c:\users\takatoh\documents\w\myproject.venv\scripts\python.exe -m pip install --upgrade pip' command.

pip の新しいバージョンがあるのでバージョンアップしておく。

(.venv) takatoh@montana: myproject > python -m pip install --upgrade pip

setup.pyと関連ファイル

setup.py はパッケージングに使うスクリプトだけど、もろもろの設定はこのファイルには書かないのが最近の作法らしい。だから中身はこれだけ:

import setuptools


setuptools.setup()

じゃあ、設定はどこに書くのかというと setup.cfg というファイルに書く。書式も ini ファイル風。

[metadata]
name = myproject
version = attr:myproj.__version__
description = My Python project example.
author = takatoh
author_email = [email protected]
license = MIT License
classifier =
    License :: OSI Approved :: MIT License
    Programming Language :: Python
    Programming Language :: Python :: 3

[options]
zip_safe = False
packages = find:
include_package_data = True
entry_points = file:entry_points.cfg
install_requires =
    click

パッケージが実行コマンドを含むので [options] セクションに entry_points = file:entry_points.cfg を指定している。entry_points.cfg ファイルにコマンド名と実際に実行される関数を書くようになっている。その entry_points.cfg はこう:

[console_scripts]
myproj = myproj.command:main

もうひとつ、パッケージに含むファイルについて。packages = find: という記述で Python のスクリプトファイルを含めることができる。だけど場合によってはスクリプトでないファイルを含めたいこともある。img2epub ではテンプレートファイルが必要だった。そういう時には include_package_data = True としておいて、MANIFEST.in にファイルを列挙する。こんな感じ:

include myproj/data/greeting.txt

ファイルは setup.py(あるいは setup.cfg というべき?)からの相対パスで書く。

というわけで、これでパッケージングの準備は完了。設定内容自体は以前の setup.py に書くスタイルと変わらないんだけど、なんかファイルが分散してわかりにくくなったんじゃないかなぁ。

pip install -e .

pip install -e . コマンドは、開発中のパッケージを「開発モード」でインストールしてくれる。

(.venv) takatoh@montana: myproject > pip install -e .
Obtaining file:///C:/Users/takatoh/Documents/w/myproject
Collecting click
Using cached click-7.1.2-py2.py3-none-any.whl (82 kB)
Installing collected packages: click, myproject
Running setup.py develop for myproject
Successfully installed click-7.1.2 myproject

依存パッケージがあれば一緒にインストールしてくれるのはもちろんだけど、開発中のパッケージのファイルを更新すると、自動的に反映してくれる。

(.venv) takatoh@montana: myproject > pip list
Package    Version Location
---------- ------- --------------------------------------
click      7.1.2
myproject  0.0.1   c:\users\takatoh\documents\w\myproject
pip        20.2.4
setuptools 49.2.1

myproject だけパスが書いてある。これが開発中のパッケージだ。

※まだ書きかけ。

参考ページ

ひとそろいの画像ファイルからEPUB(電子書籍)ファイルを作る

Qiita の↓の記事を読んで、EPUB って結構簡単(もちろん単純なものなら)なんだな、と思ったので Python でスクリプトを作ってみた。

作ったもの

フォルダに入った画像ファイル一式から EPUB ファイルを生成する。

  • 1ページ1画像のファイル一式
  • とりあえず PNG にだけ対応
  • 画像ファイルはファイル名でソートするので連番でなくても構わない
  • 目次とかそういうのはなし
  • 元データのフォルダ名が EPUB のタイトル、ファイル名になる

ファイル構成は次の通り:

takatoh@montana: img2epub > tree /f .
フォルダー パスの一覧
ボリューム シリアル番号は 681C-8AA1 です
C:\USERS\TAKATOH\DOCUMENTS\W\IMG2EPUB
│  .gitignore
│  img2epub.py
│
├─data
│      book.opf.template
│      chap1.xhtml.template
│      container.xml
│      nav.xhtml
│
└─sample
        sample-000.png
        sample-001.png
        sample-002.png
        sample-003.png
        sample-004.png
        sample-005.png
        sample-006.png
        sample-007.png

img2epub.py が Python で書いたスクリプト本体。data フォルダ以下のファイルは EPUB を構成するファイルあるいはそのテンプレート。スクリプトは次の通り:

#!/usr/bin/env python
# encoding: utf-8


import sys
import os
import shutil
import subprocess
from datetime import datetime, timezone
import uuid
import glob
from jinja2 import Template, Environment, FileSystemLoader


def main():
    src_dir = sys.argv[1]
    now = datetime.now(timezone.utc)
    tmp_dir_name = "tmp.epub.{time}".format(time=now.strftime("%Y%m%d%H%M%S"))

    make_dirs(tmp_dir_name)
    images = copy_images(src_dir, tmp_dir_name)
    images = [s.replace("\\","/") for s in sorted(images)]
    gen_mimetype(tmp_dir_name)
    copy_container(tmp_dir_name)
    book_opf_context = {
        "title": src_dir,
        "time": now.isoformat(),
        "images": images
    }
    gen_book_opf(tmp_dir_name, book_opf_context)
    copy_nav(tmp_dir_name)
    gen_chap1_xhtml(tmp_dir_name, book_opf_context)
    zip_epub(tmp_dir_name, src_dir)


def make_dirs(tmp_dir_name):
    os.makedirs(os.path.join(tmp_dir_name, "META-INF"))
    os.makedirs(os.path.join(tmp_dir_name, "EPUB"))


def copy_images(src_dir, tmp_dir_name):
    images_dir = os.path.join(tmp_dir_name, "EPUB/images")
    shutil.copytree(src_dir, images_dir)
    return glob.glob("{dir}/*".format(dir=images_dir))


def gen_mimetype(tmp_dir_name):
    with open(os.path.join(tmp_dir_name, "mimetype"), "w") as f:
        f.write("application/epub+zip")


def copy_container(tmp_dir_name):
    shutil.copyfile("data/container.xml", os.path.join(tmp_dir_name, "META-INF/container.xml"))


def gen_book_opf(tmp_dir_name, context):
    env = Environment(loader=FileSystemLoader("data"))
    template = env.get_template("book.opf.template")
    context["images"] = [s.replace("{tmp}/EPUB".format(tmp=tmp_dir_name), ".") for s in context["images"]]
    context["cover"] = context["images"][0]
    context["uuid"] = str(uuid.uuid4())
    with open(os.path.join(tmp_dir_name, "EPUB/book.opf"), "w") as f:
        f.write(template.render(context))


def copy_nav(tmp_dir_name):
    shutil.copyfile("data/nav.xhtml", os.path.join(tmp_dir_name, "EPUB/nav.xhtml"))


def gen_chap1_xhtml(tmp_dir_name, context):
    env = Environment(loader=FileSystemLoader("data"))
    template = env.get_template("chap1.xhtml.template")
    images = [s.replace("{tmp}/EPUB".format(tmp=tmp_dir_name), ".") for s in context["images"]]
    with open(os.path.join(tmp_dir_name, "EPUB/chap1.xhtml"), "w") as f:
        f.write(template.render(images=images))


def zip_epub(tmp_dir_name, title):
    epub_file_name = "../{title}.epub".format(title=title)
    os.chdir(tmp_dir_name)
    subprocess.run(["zip", "-X0", epub_file_name, "mimetype"], stdout=subprocess.DEVNULL)
    subprocess.run(["zip", "-r9", epub_file_name, "*", "-x", "mimetype"], stdout=subprocess.DEVNULL)
    os.chdir("..")



main()

で、sample フォルダ以下が元になる画像ファイル一式。

使い方

スクリプトの引数に画像一式が入っているフォルダを指定するだけ。

takatoh@montana: img2epub > python img2epub.py sample

そうすると、EPUB ファイル(今回は sample.epub)と、EPUB にまとめる前のファイル一式の入ったフォルダ(同じく tmp.epub.20201027130853)ができる。このフォルダはテンポラリなものなので消しちゃってもいいんだけど、今の段階ではまだ残している。

takatoh@montana: img2epub > ls


    Directory: C:\Users\takatoh\Documents\w\img2epub

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d----          2020/10/27    20:07                data
d----          2020/10/27    20:09                sample
d----          2020/10/27    22:08                tmp.epub.20201027130853
-a---          2020/10/27    20:07              9 .gitignore
-a---          2020/10/27    20:07           2832 img2epub.py
-a---          2020/10/27    22:08        8011218 sample.epub

あとは出来上がった sample.epub ファイルを好きな EPUB ビューワで見ればいい。

EPUB のファイルの構成

EPUB のファイルっていうのは、基本的には使用で決められたファイルとコンテンツを zip で一つにまとめて、拡張子を .epub にしただけのファイルだ。単純に zip にしただけではないんだけど、そのへんはこの記事では触れない。冒頭の Qiita の記事か、EPUB 3.2 の仕様を参照のこと。

今回作ったスクリプト img2epub.py では、tmp.epub.* フォルダにその一式が入っている(つまりこれを zip 圧縮して .epub ファイルを作る)。フォルダの中身は次の通り:

takatoh@montana: img2epub > tree /f tmp.epub.20201027130853
フォルダー パスの一覧
ボリューム シリアル番号は 681C-8AA1 です
C:\USERS\TAKATOH\DOCUMENTS\W\IMG2EPUB\TMP.EPUB.20201027130853
│  mimetype
│
├─EPUB
│  │  book.opf
│  │  chap1.xhtml
│  │  nav.xhtml
│  │
│  └─images
│          sample-000.png
│          sample-001.png
│          sample-002.png
│          sample-003.png
│          sample-004.png
│          sample-005.png
│          sample-006.png
│          sample-007.png
│
└─META-INF
        container.xml

EPUB/images 以下の画像ファイルは、元のデータをコピーしたもの。そのほかのファイルはスクリプトが生成したファイルだ。詳しくは略。

余談

EPUB を構成するファイルの一部(book.opf や chap1.xhtml)を生成するためにテンプレートエンジンを使ってるんだけど、Python には string.Template というテンプレートエンジンが標準でついている。これ、今回調べてて初めて知った。

ところがこの string.Template、単純な値の挿入はできるけど繰り返しや条件分岐の機能がない。今回、条件分岐は使ってないけど繰り返すは必要だったので、結局 Jinja2 を使った。標準添付されてるのはいいけど、変数を値に置き換えるだけしかできないんじゃ、用途は限られるよなぁ。

Python: BOMつきUTF-8のCSVファイルを読み込む

Windows の Excel で、CSV ファイルに UTF-8 で出力ができることを知った。調べてみると結構前からできるようになってたようだ。

Excel で作ったデータを CSV ファイルに出力して Python のスクリプトで処理する、っていうのを時々やるんだけど、今まではいったん Shift JIS で出力したのを秀丸エディタを使って UTF-8 に変換してから処理していた。スクリプトは Linux でも使うから入力データのエンコーディングは UTF-8 にしておきたいんだよね。

で、Excel が UTF-8 の CSV をはいてくれるなら面倒な変換の手間を省いてそのままスクリプトで処理できる……と思ってやってみたらエラーになった。BOM(バイトオーダーマーク)がついているのが原因らしい。UTF-8 に BOM がついてるのとついてないのがあるのは知ってたけど、気にしたことはなかった。今回初めて BOM つきの UTF-8 に出くわした。軽くググってみると Windows だけが BOM をつけるらしい。

さて、本題。

Python で BOM つきの UTF-8 を読み込むには、ファイルを開くときのエンコーディングに utf-8-sig を指定してやればいい。↓こんな感じ。

>>> import csv
>>> with open('data_with_bom.csv', encoding='utf-8-sig') as f:
...     for row in csv.reader(f):
...         if row[0]:
...             print(row[0])
...

utf-8-sig というエンコーディングは BOM なしの UTF-8 も扱える。なので UTF-8 であることがわかっていれば BOM を気にしなくていい。

>>> with open('data_without_bom.csv', encoding='utf-8-sig') as f:
...     for row in csv.reader(f):
...         if row[0]:
...             print(row[0])
...

というわけで、解決。

[余談]

コード中に if row[0]: とあるのは Excel のはいた CSV ファイルの後ろのほうにカンマだけの行がくっついてるからそれを避けるため。なんでそんな行がくっつくのかは謎。

っていうか Python って後置の if が使えないんだね。ちょっと使いにくいな。

Python: Flask: HTTP GET メソッドのクエリパラメータに真偽値を使いたい

うまい例をでっち上げられないので、実際のユースケースで説明する。

自宅のサーバで Flask を使った書籍管理の web アプリを運用してるんだけど(ソースコードは GitHub で公開してある)、その web アプリには JSON を返す API が定義してある。で、API のひとつ /api/books/ は書籍情報の一覧を返し、クエリパラメータとして offset と limit を取ることができる。

Flask では request.args.get でクエリパラメータの値を取得するついでに、値の型とデフォルト値を指定することができる。↓こんな感じ。

@app.route('/api/books/')
def api_books():
    offset = request.args.get('offset', default=0, type=int)
    limit = request.args.get('limit', default=100, type=int)
    ....

さて、この書籍管理アプリには「書籍を削除する」という機能がなくて、かわりに書籍を捨てた時には、データベース上の disposed カラムに True をセットして、web アプリ上では表示しないようになっている。だけど上記の API ではそこのところを考慮してなかったので、返ってくる JSON には捨てた書籍も含まれている、という状況だった。

ここからが本題。

API の返す JSON に、デフォルトでは捨てた書籍(つまり disposed=True)は含まず、クエリで include_disposed に真値をセットした時にだけ含むようにしたい。

最初の方法

上に示したコートでは、クエリパラメータの値を int に変換して取得しているんだから、同様に bool を指定してやればいいと思った。こういうふうに。

@app.route('/api/books/')
def api_books():
    offset = request.args.get('offset', default=0, type=int)
    limit = request.args.get('limit', default=100, type=int)
    include_disposed = request.args.get('include_disposed', default=False, type=bool)
    ....

開発用のサーバはエラーもなく立ち上がり、/aip/books/ にアクセスすると捨てた書籍が含まれていない JSON が、/api/books/?include_disposed=True にアクセスすると捨てた書籍も含まれた JSON が返ってきた。期待通りだ。

ところが、試しに /api/books/?include_disposed=False にアクセスしてみると、捨てた書籍も含まれた JSON が返ってきた。これは期待する動作と違う。

原因は追求していないけど、想像するに、クエリの値として渡ってきた文字列を bool(文字列) してるだけなんじゃなかろうか。だとすると、クエリで include_disposed の値がなんであるかにかかわらず、空文字列でない限りは、変数 include_disposed の値は True になるわけだ。

なんてこった。期待させやがって!

解決編

しかたがないので文字列を真偽値に変換する関数を書いた。一般に真を表すであろう文字列(true、 yes、 on、大文字小文字を問わない)の時だけ True を返し、ほかは False を返す。

def str_to_bool(s):
    p = re.compile(r'(true|yes|on)\Z', re.IGNORECASE)
    if p.match(s):
        return True
    else:
        return False

で、クエリの値を変数に代入するところはこうした。

    include_disposed = str_to_bool(request.args.get('include_disposed', default=''))

request.args.get の返す値を str_to_bool で変換している。default=” を指定しているのは、指定しないとクエリに include_disposed がなかった時に None が返ってくるため。

さて、これで期待通りの動作をするようになった。

Pythonで全文検索を実装してみた

JavaScript でやってるのを見かけたので。

 cf. JavaScriptで全文検索(N-gram)を実装してみる! – Simple is Beautiful.

アルゴリズムは N-gram っていう方法のうちでも簡単な uni-gram っていうみたい。詳しくはリンク先の記事を見て。

記事の説明を読んで、なんとなく理解したので Python で書いてみた。リンク先の JavaScript の実装はファイル名を参考にしたくらいで、ちゃんと読んでない。

できたのはこんな感じ。

  • create_index.py でインデックスを作って、search.py で検索
  • 検索対象のファイルはテキストファイルのみ。documents ディレクトリに入ってる
  • インデックスファイルは indexes ディレクトリ内につくる
  • document ID とファイル名の対応は docs.json ファイル

インデックスを作る create_index.py

from unigram import document
import json
import os


DOC_DIR = 'documents'
INDEX_DIR = 'indexes'
DOC_DATA = 'docs.json'


files = [os.path.join(DOC_DIR, f) for f in os.listdir(DOC_DIR)]
docs = {}
doc_id = 0
for file in files:
    with open(file, 'r') as f:
        text = f.read()
        tokens = document.tokenize(text)
        index = document.classify(tokens)
        document.save_index(index, doc_id, INDEX_DIR)
    docs[str(doc_id)] = {'name': os.path.basename(file), 'path': file}
    doc_id += 1

with open(DOC_DATA, 'w') as f:
    json.dump(docs, f, indent=2)

検索コマンド search.py

from unigram import document
import json
import os
import sys


INDEX_DIR = 'indexes'
DOC_DATA = 'docs.json'


string = sys.argv[1]

with open(DOC_DATA, 'r') as f:
    docs = json.load(f)
fcount = len(docs)

index_files = list(map(lambda x: os.path.join(INDEX_DIR, x), os.listdir(INDEX_DIR)))
index = {}
for file in index_files:
    c = chr(int(os.path.basename(file).replace('.index', '')))
    with open(file, 'r') as f:
        index[c] = document.parse_index(f.read())

m = list(map(lambda x: index[x], list(string)))

for i in range(fcount):
    doc_id = str(i)
    if not all(map(lambda x: doc_id in x.keys(), m)):
        continue
    n = list(map(lambda x: x[doc_id], m))
    s = set(n[0])
    for s1 in n[1:]:
        s = set(list(map(lambda x: x + 1, s)))
        s = s & set(s1)
    if len(s) > 0:
        pos = list(map(lambda x: x - len(string) + 1, s))
        pos.sort()
        print(docs[doc_id]['name'], pos)

両方から使うモジュール unigram/document.py

import os


def tokenize(text):
    return list(text)


def classify(token_list):
    tokens = {}
    pos = 0
    for t in token_list:
        if not t in tokens:
            tokens[t] = []
        tokens[t].append(pos)
        pos += 1
    return tokens


def save_index(index, doc_id, index_dir):
    for c, idx in index.items():
        l = str(doc_id) + ':' + ','.join(list(map(lambda x: str(x), idx))) + '\n'
        with open(os.path.join(index_dir, str(ord(c)) + '.index'), 'a') as f:
            f.write(l)


def parse_index(content):
    l = content.split('\n')
    l.pop()
    index = {}
    for l2 in l:
        a = l2.split(':')
        index[a[0]] = [int(x) for x in a[1].split(',')]
    return index

とにかくまずは動くものを、ってことで作ったので、コードが整理されてないのは目を瞑ってほしい(後で直す)。

検索対象ファイルのサンプルには、別のプロジェクトで書いた Ruby のソースファイル。

takatoh@apostrophe $ ls documents
Gemfile       Rakefile  boot.rb    config.yaml
Gemfile.lock  app.rb    config.ru  config.yaml.example

インデックスを作る。

takatoh@apostrophe $ python create_index.py

できたインデックスファイルがこれ。

takatoh@apostrophe $ ls indexes
10.index   111.index  123.index  44.index  58.index  71.index  84.index
100.index  112.index  124.index  45.index  60.index  72.index  85.index
101.index  113.index  125.index  46.index  61.index  73.index  87.index
102.index  114.index  126.index  47.index  62.index  74.index  89.index
103.index  115.index  32.index   48.index  63.index  75.index  91.index
104.index  116.index  34.index   49.index  64.index  76.index  93.index
105.index  117.index  35.index   50.index  65.index  77.index  95.index
106.index  118.index  36.index   51.index  66.index  78.index  97.index
107.index  119.index  39.index   52.index  67.index  79.index  98.index
108.index  120.index  40.index   53.index  68.index  80.index  99.index
109.index  121.index  41.index   55.index  69.index  82.index
110.index  122.index  43.index   56.index  70.index  83.index

「require」という文字列を検索してみる。ファイル名と出現位置(のリスト)が出力される。

takatoh@apostrophe $ python search.py require
boot.rb [0, 17]
Rakefile [0, 15, 32, 71]
config.ru [0]
app.rb [0, 23, 40, 56, 71]

大丈夫そうだ。

CentOS 8 をインストールしてみた

新しい PC を買ったんだ。これまで Dell か HP だったので、今回は Lenovo にしてみた。ThinkCentre M630e Tiny っていうモデル。てのひらにはちょっとあまるけど、超小型 PC と言っていい大きさだ。

で、せっかくなので最近リリースされたばかりの CentOS 8.0.1905 をインストールしてみた。プレインストールされていた Windows 10 は削除。

インストーラは CentOS 7 と特段変わらない印象。でもデフォルトのインストールタイプ(っていうんだっけ?)が、「サーバー(GUI使用)」になってた。もちろんこれでOK。ひととおりインストールが済んで再起動すると、ちゃんと CentOS が立ち上がった。いつかの Dell の PC みたいに起動しないなんてことはなかった。いい兆候だ。あと、ホスト名は rollo にした。

日本語入力

インストール時に日本語を選んでいるので、メニューとかは日本語になってるんだけど、そのままでは入力はできないようだ。↓このページが参考になった。

cf. デスクトップ環境 : GNOME デスクトップ インストール – Server World

まずは日本語入力プログラムをインストール。

[takatoh@rollo ~]$ sudo dnf -y install ibus-kkc

CentOS 7 までの yum コマンドじゃなくて dnf っていうコマンドでパッケージをインストールするらしい。

それから、背景画面の適当なところで右クリックして設定ウィンドウをひらいて、 Region & Language に移動する。入力ソースの欄で、日本語(かな漢字)を追加すれば準備は完了。画面右上のアイコンをクリックして日本語(かな漢字)を選べば OK だ。あとは半角/全角キーで入力モードを切り替えられる。今日のこのエントリもそうやって書いている。

Ruby とか Python とか

Python が入ってない、と思ったら python3 コマンドだった。

[takatoh@rollo ~]$ python3 -V
Python 3.6.8

Python はデフォルトが 3 系になったらしい。なら python コマンドでいいだろうになんで python3 なんだ。

Ruby は入ってない。のでインストールした。

[takatoh@rollo ~]$ ruby -v
bash: ruby: コマンドが見つかりませんでした…
コマンド ruby' を提供するためにパッケージ 'ruby' をインストールしますか? [N/y] y
キューで待機中… 
パッケージの一覧をロード中。… 
以下のパッケージはインストールされるべきものです:
ruby-2.5.3-104.module_el8.0.0+179+565e49e2.x86_64    An interpreter of object-oriented scripting language
ruby-irb-2.5.3-104.module_el8.0.0+179+565e49e2.noarch    The Interactive Ruby
ruby-libs-2.5.3-104.module_el8.0.0+179+565e49e2.x86_64    Libraries necessary to run Ruby
rubygem-bigdecimal-1.3.4-104.module_el8.0.0+179+565e49e2.x86_64    BigDecimal provides arbitrary-precision floating point decimal arithmetic
rubygem-did_you_mean-1.2.0-104.module_el8.0.0+179+565e49e2.noarch    "Did you mean?" experience in Ruby
rubygem-io-console-0.4.6-104.module_el8.0.0+179+565e49e2.x86_64    IO/Console is a simple console utilizing library
rubygem-json-2.1.0-104.module_el8.0.0+179+565e49e2.x86_64    This is a JSON implementation as a Ruby extension in C
rubygem-openssl-2.1.2-104.module_el8.0.0+179+565e49e2.x86_64    OpenSSL provides SSL, TLS and general purpose cryptography
rubygem-psych-3.0.2-104.module_el8.0.0+179+565e49e2.x86_64    A libyaml wrapper for Ruby
rubygem-rdoc-6.0.1-104.module_el8.0.0+179+565e49e2.noarch    A tool to generate HTML and command-line documentation for Ruby projects
rubygems-2.7.6-104.module_el8.0.0+179+565e49e2.noarch    The Ruby standard for packaging ruby libraries
変更したまま継続しますか? [N/y] y
キューで待機中… 
認証を待ち受け中… 
キューで待機中… 
パッケージをダウンロード中… 
データを要求中… 
変更をテスト中… 
パッケージのインストール中… 
ruby 2.5.3p105 (2018-10-18 revision 65156) [x86_64-linux] 
[takatoh@rollo ~]$ ruby -v
ruby 2.5.3p105 (2018-10-18 revision 65156) [x86_64-linux]

2.5.3 がインストールされた。

というところで、今日はここまで。

3本のひもで作る四角形

3月も下旬になってしまった。

今回もたまたま見つけたネタ。前回と同じブログから。

 cf. 3本のひもで作る四角形 – utthi_fumiの日記

同じ長さの3本のひもを折り曲げて3つの四角形を作ります。 そのうち2本でそれぞれ長方形を作り、残りの1本は正方形を作ります。 このとき、作った2つの長方形の面積の和が、正方形の面積と同じになることがあります。 (ただし、いずれの長方形、正方形も辺の長さは整数)

例)紐の長さが20の時

1本目 縦1×横9の長方形→面積=9

2本目 縦2×横8の長方形→面積=16

3本目 縦5×横5の正方形→面積=25

さらに、ひもの長さを変えてできる長方形と正方形の組をカウントします。 ただし、同じ比で整数倍のものは1つとしてカウント

例)紐の長さが40の時

1本目 縦2×横18の長方形→面積=36

2本目 縦4×横16の長方形→面積=64

3本目 縦10×横10の正方形→面積=100

紐の長さが60の時

1本目 縦3×横27の長方形→面積=81

2本目 縦6×横24の長方形→面積=114

3本目 縦15×横15の正方形→面積=225

紐の長さを1から500まで変化させる時、2つの長方形の面積の和と 正方形の面積が同じなる組が何通りあるか?


Python でやってみる。

import math
from functools import reduce


def main():
    s = set()
    for l in range(1, 501):
        for c in height_of_rectangles(l):
            s.add(reduction(c))
    for c in s:
        print(format_rect(c))
    print(len(s))

def combi(l):
    if l % 4 == 0:
        h3 = int(l / 4)
        return [(h1, h2, h3) for h2 in range(1, h3) for h1 in range(1, h2)]
    else:
        return []

def area(h, l):
    w = int(l / 2) - h
    return h * w

def height_of_rectangles(l):
    hor = []
    for c in combi(l):
        (h1, h2, h3) = c
        if area(h1, l) + area(h2, l) == area(h3, l):
            hor.append(c)
    return hor

def format_rect(c):
    (h1, h2, h3) = c
    l = h3 * 4
    w1 = h3 * 2 - h1
    w2 = h3 * 2 - h2
    return "{l}: {h1} * {w1} + {h2} * {w2} = {h3} * {h3}".format(l=l, h1=h1, w1=w1, h2=h2, w2=w2, h3=h3)

def reduction(c):
    r = reduce(math.gcd, c)
    return tuple(map(lambda x: int(x / r), c))


if __name__ == '__main__':
    main()

実行結果:

116: 8 * 50 + 9 * 49 = 29 * 29
260: 9 * 121 + 32 * 98 = 65 * 65
100: 1 * 49 + 18 * 32 = 25 * 25
340: 8 * 162 + 49 * 121 = 85 * 85
388: 25 * 169 + 32 * 162 = 97 * 97
164: 1 * 81 + 32 * 50 = 41 * 41
404: 2 * 200 + 81 * 121 = 101 * 101
68: 2 * 32 + 9 * 25 = 17 * 17
52: 1 * 25 + 8 * 18 = 13 * 13
260: 2 * 128 + 49 * 81 = 65 * 65
212: 8 * 98 + 25 * 81 = 53 * 53
500: 8 * 242 + 81 * 169 = 125 * 125
436: 18 * 200 + 49 * 169 = 109 * 109
20: 1 * 9 + 2 * 8 = 5 * 5
340: 1 * 169 + 72 * 98 = 85 * 85
244: 1 * 121 + 50 * 72 = 61 * 61
148: 2 * 72 + 25 * 49 = 37 * 37
292: 18 * 128 + 25 * 121 = 73 * 73
356: 9 * 169 + 50 * 128 = 89 * 89
452: 1 * 225 + 98 * 128 = 113 * 113
20

いちばん最後に出力されている 20 が答え。20 通りあるってこと。

整数の割り算の結果が小数になることとか、map の結果が map object とかいうものになるとことかでちょってハマった。map object ってなにさ。