ぽよメモ

ファッション情報学徒の備忘録.

公開さえすればいいってもんじゃない

TL;DR

  • 研究で開発したツールを公開するのはとても良いこと
  • 頼むから必要な依存関係の情報を残してくれ
  • 頼むからREADMEを書いてくれ
  • 頼むからライセンスを明記してくれ
  • 頼むからDockerイメージになんでも突っ込まないでくれ

はじめに

 これはあくあたん工房お盆休みアドベントカレンダー7日目の記事です.

 今日は開発者向けではなく,研究者向けの話をします.主にコンピュータサイエンスの話になりますが,ソフトウェアを書く必要のある分野であればどれもそう変わらないだろうと思います.
 研究でソフトウェアを書いている人,それをGitHubで公開している/しようとしている人へ向けて書いています.僕の専攻分野,研究内容による影響でこの記事の内容には偏りがあります.全ての分野に当てはまるとは言えません.よろしくお願いします.

研究とソフトウェア

 コンピュータサイエンスの多くの研究では,規模の大小こそあれど,ソフトウェア(ないしスクリプト)を書いて実験を回すことになります.その過程でできたものをGitHub等で広く公開することも,最近では見られるようになってきました.論文に掲載された理論と実際の実装があれば,それを拡張することも,検証をおこなうことも容易になり,大変便利でありがたいです.しかし,そうして公開されるソフトウェアの多くは様々な要因が重なり,はっきり言うと使い物にならないことが多いです.
 色々とその原因はあるのでしょうが,僕個人は,研究における開発は研究者当人の環境で動けば問題ないことが多く,他環境での再現性には注意が払われないという事態にしばしば陥りがちであるためと考えています.これは以下の様な事態を引き起こします.

  • それを動かすための依存関係の情報が欠落している
  • 使用方法が書いておらず,入力するデータをどのような形式で用意すれば良いのかわからない.
  • ライセンスの表記がない

特に「依存情報の欠落」と「ライセンスの欠落」は致命的であり,このようなリポジトリは基本的に使用不可能に近くなってしまいます.卒業研究のような短期的に誰かが研究を行い,その後続の研究を誰か他の人が行うような事例の場合でもこれらの問題は顕在化し,しばしば技術/研究の継承を途切れさせてしまいます.
 今回はこれらについて,最近使用されることが多くなってきたPythonにフォーカスし,いくつかの対処法を紹介していこうと思います.

依存関係の管理

どうして依存関係管理が必要なのか

 使用するライブラリだけでなく,Python自体も,定期的な機能更新が行われています.しかし,テキストで記述されているソースコードから,使用されているライブラリ・Pythonのバージョンを推測することは容易ではなく,適していないバージョンのものを用いると,実行時にエラーが発生したり,予期しない結果になる恐れもあります.
 また,使用されているライブラリはスクリプトを解析すれば分かるかも知れませんが,OS側にインストールが必要なものがあるとか,特定のハードウェアが必要であるとか,そういった情報も,開発した当人にとっては当たり前かも知れませんが,利用者にとってはそうではありません.これが依存関係の適切な管理・および把握が必要な理由です.

なぜAnacondaは微妙なのか

 研究開発において,よくAnacondaが使用されています.

www.anaconda.com

はっきり言うとAnacondaは必要な依存関係を隠蔽し,開発環境をガラパゴス化させる大きな要因の一つであると考えています.
 Anacondaには,最初からビルド済みのデータ解析用パッケージが多数含まれており,パッケージのインストールのような特別な操作をわざわざしなくても最初から様々なパッケージの恩恵を受けることが出来ます.特にIntel MKLにリンク済みのnumpyなど,通常のインストールでは面倒なものを簡単にインストールできるため,大変人気です.しかし一方で,

  • どのパッケージを自分は実際に使っているのか
  • そのパッケージのどのバージョンを利用しているのか

といった情報からユーザを隔離してしまいがちです.仮想環境を作って運用していたとしても,その状態は明示的に残さない限りリポジトリには含まれないため,気をつけて運用する必要があります.
 深層学習・機械学習は進歩がめざましく,パッケージの更新も頻繁にあるため,以前のAPIが使用不能になることは多く見られます.特に直近ではTensorflowが2.0への移行を進めており,将来的にデフォルトのバージョン*1が切り替わる事になります.ある実装がどのバージョンに対応しているのか,という情報が必要不可欠であることがわかります.

対処法1:Minicondaと仮想環境を使う

 Windowsユーザは実質的にこれを選ぶしかありません.MinicondaはAnacondaの軽量版であり,自分でパッケージを選択してインストールする必要があります. まずは1つの研究プロジェクト,ないしソフトウェアリポジトリごとに1つの仮想環境を作成してください.これにより,研究ごとのPython環境が隔離されるため,異なるプロジェクト間で異なるバージョンのパッケージを要求されても対応できるようになったり,どのパッケージが必要となっているのかが明白になったりします.
condaコマンドを使って仮想環境を作成できます.

# 仮想環境の作成
$ conda create -n 仮想環境名 "python==使うバージョン"
$ conda install -n 仮想環境名 numpy
# 環境へ入る
$ conda activate 仮想環境名
# 環境から抜ける
$ conda deactivate

このように作成した仮想環境の情報を明示的に残すためにはconda env exportというコマンドを使用できます.

# env.ymlとして環境の情報を出力する
$ conda env export -n 仮想環境名 -f env.yml

こうして出力されたenv.ymlには以下の様なフォーマットでパッケージが記録されています.

name: 仮想環境名
channels:
  - defaults
dependencies:
  - パッケージ名=バージョン=ビルド番号
  - pip:
    - パッケージ名==バージョン
prefix: /path/to/仮想環境名

このファイルを共有しておけば,OS,アーキテクチャを揃えれば他の環境でもconda env create -f env.ymlで同等の環境を復元することが出来ます.ただし,新しいパッケージをインストールしても自動的には反映されないため,変更したら再びexportする必要があります.

対処法2:pipenvで自動的に管理する

 Pipenvはrequestsなどの有名パッケージの作者であるKenneth Reitz氏による,新しい依存関係管理ツールです.pipと似たインターフェースを備え,PipfileおよびPipfile.lockにより依存関係のバージョンを固定しています.

# pipenvのインストール
$ pip install --user pipenv
# 仮想環境の作成.PipfileおよびPipfile.lockが作成される
# 明示的に使用するPythonを指定する場合
# pipenv install --python /path/to/python
# または
# pipenv install --python バージョン
# ただし,pyenv等でこれらのバージョンが使用可能になっている必要がある
$ pipenv install
# パッケージのインストール
$ pipenv install numpy
# 仮想環境に入ることなく,仮想環境内のコマンドを呼ぶ
$ pipenv run python main.py
# 仮想環境に入る
$ pipenv shell

今後はpipenv installuninstallを行うごとにPipfileおよびPipfile.lockが更新されるため,これらをリポジトリに含めておけば良いです.Anaconda/Minicondaと違い,pipと同じパッケージを参照するため,MKL等は自分で導入する必要があります.

詳細な使い方は以下の参考リンクをご覧ください.

narito.ninja

対処法3:setup.pyを書く

 これは上記対処法1・2と共存できます.完璧にバージョンを指定するのではなく緩いバージョン制約を設けること,そしてそのパッケージをその仮想環境へインストールする事が出来るようになります.

 まずプロジェクトのディレクトリ構造を以下の様にします.今回作るプロジェクトで使用するPythonモジュール群をpackage_aディレクトリ以下に置くものとします.

.
├── LICENSE
├── README.md
├── setup.cfg
├── setup.py
└── package_a
    ├── __init__.py
    └── hoge.py

setup.pyを書きます.以下の内容だけで良いです.

from setuptools import setup

setup()

setup.cfgを書きます.

[metadata]
name = パッケージ名
version = バージョン
# バージョンをPythonスクリプト内の変数から取ることも出来る
# package_aの__init__.pyに`version = "1.0.0"`などと宣言しておけば以下で参照できる
# version = attr: package_a.version
author = 著作者
author_email = 著作者の連絡先
description = 説明
url = https://github.com/yourname/yourrepo
# ライセンスファイルへのパスを指定
license_file = LICENSE

[options]
# Pythonバージョンの制約.以下だと3.5以上4.0未満
pythonrequire = >=3.5,4.0>
# 含めるパッケージの選択.この場合は自動で探索
packages = find:
# 列挙する場合は普通にインストールするパッケージを羅列すれば良い
# ただしトップレベルパッケージのみ書いてもそれ以下のサブパッケージは含まれない
# packages =
#    package_a

# 依存関係の列挙
install_requires =
    # 以下なら1.0以上,2.0未満のhogeを要求する
    hoge>=1.0,<2.0
    fuga

[options.packages.find]
# 自動探索から外すものを列挙
exclude =
    tests
    piyo

こうしておけば,setup.pyと同じ階層でpip install -e .(pipenvの場合はpipenv install -e .)でEditableモードとしてインストールできます.package_aからのパスでのimportができ,コードを編集すればそれがそのまま反映されるため,何度もインストールする必要がありません.

# インポートする
from package_a import hoge

hoge.~~~

対処法4:Dockerイメージ化する

 最近ではDockerが使用されることも多いです.この場合,OSレベルから依存関係を構築出来るため,より高い再現性を得られます.また,Dockerイメージを直接配布できるため,他環境で何度も環境を作るといった手間を省けるというメリットもあります.一方で,Linuxのコンテナ技術を利用するため,そのほかのプラットフォーム特有の機能を利用していたり,特定ハードウェアにロックインされている場合,GUIが必須の場合は注意してください.

CUDAを利用する場合はnvidia/cudaのイメージを使うと良いです.下記の様なDockerfileと呼ばれるフォーマットのファイルを記述します.

FROM nvidia/cuda:10.0-cudnn7-devel-ubuntu18.04

RUN apt-get update -y && \
    apt-get install \
        python3 \
        python3-pip

RUN pip install \
    tensorflow==1.14 \
    numpy==1.16

COPY . /opt/hoge

CMD ["python3", "/opt/hoge/main.py"]

ピュアなPythonイメージ(FROM python:バージョン)を利用することもできます.CUDAが不要な場合はその方がイメージサイズが小さく,オススメです.Debianベースなので同じようにapt-getが使えます.

これも詳しくは参考リンクをご覧ください

moritomo7315.hatenablog.com

Dockerアンチパターン

やってほしくない例です.

バージョン指定していないイメージ
FROM python

RUN pip install tensorflow

このようなものはビルドごとに最新のものを使ってしまうため,異なるイメージが作成されてしまいます.バージョンはしっかりと指定してほしいです.

FROM python:3.6

RUN pip install tensorflow==1.14
なんでもイメージ

あらゆるライブラリ全部網羅!これさえあればOK!みたいなイメージは,結局のところ無駄にリソースを消費し,依存関係をややこしくするだけです.

  • 初心者がとっかかりに使う
  • Kaggleなどで汎用的に使える環境のために使う

のは良いかも知れませんが,再現性のための環境の配布でこういったことを行うのは明らかに悪手です.Web上ではよく見かけますが,こうしたイメージを使うならDockerでなくてもいいじゃんという気持ちです.

データ自体が入ったイメージ

大容量データセットをイメージ自体に含めるのは最悪なのでやめてください.イメージをローカルに展開しただけでシステムの容量を一気に持って行かれるのは大変困ります.
Dockerにはホストのディレクトリをマウントする機能があるため,データセットとイメージの配布は別々に行うべきです.

# カレントディレクトリのdatasetディレクトリをコンテナの/datasetにマウントして起動する
$ docker run -v $PWD/dataset:/dataset hoge python ~~
docker commitして作ったイメージ

dockerには,起動したコンテナ内での作業を永続化してイメージを作成するためのcommitという機能があります.いくつかのWebサイトには

  1. dockerコンテナを起動
  2. 何か作業
  3. git commit代わりにdocker commit
  4. Dockerhubへpush

みたいな例を見かけますが,いたずらにイメージの肥大化を招き,再現性のある環境を得ることが難しくなるだけなので辞めてください.
Dockerfileは再現性を得るためにわざわざビルド時に実行する処理を記述しているのであって,これを無視して実行結果だけを保存していくだけでは,目的を達成できません.

対処法5:READMEに書く

結局上記のどの対処法を使っても,これが必要になるという話ではあります.詳しくは次節に書きます.

その他の依存関係

 対処法1~3では,OSにインストールする必要のあるライブラリについては一部を除きサポートできません*2.READMEなどに記述しておくべきでしょう.Dockerではこれらもまとめて管理できるため,出来るならDockerfileを用意することをオススメします.

使い方を残す

 せっかく公開されていて動かすこともできるのに,どのように使用して良いか分からない!みたいな事例もあります.わかりやすい使い方が載っているかどうかだけでも,そのリポジトリが活用される可能性は大きく向上すると思います.

対処法1:READMEを書く

 一番単純な方法ですが,意外とやらない人は多く居るという気がしています.書くのが面倒,情報を更新していくのが面倒,など事情は理解出来ますが,後々の自分のためにもある程度書いておいた方が良いと思います.

  • 書いておいて欲しいこと
    • 概要:このソフトウェアを使って何を達成できるのか
    • 環境:どのOS・ライブラリを使うのか,その構築方法
    • 使い方:どういう風に使うのか
    • 設定:どういう設定項目があるのか
    • ライセンス
    • 引用のためのbibtexフォーマットのテキスト
  • 書いていなくても良いなと思うこと
    • 連絡先(何かあればissueに書けば良い)
    • Known issue(issueに書いておけば良い)

特に,ユーザが知りたいのは

  • 何を入力して
  • 何が得られるのか

という情報です.この2点がしっかり分かるだけでも,そのリポジトリに対する評価は大きく変わってくると思います.

対処法2:コマンドラインオプションを作り込む

 python main.py -hなどでヘルプメッセージを表示できるようにする,という意味です.これをしっかりと書いておけばそれ自体がドキュメントとなるため,その出力をREADMEに貼るだけでマシになります.
 Pythonではargparseというモジュールによって実装されています.簡単な使い方は下記で紹介されています.

qiita.com

より複雑なコマンドを作成する場合は拙作のCLIフレームワークもご検討ください.

poyo.hatenablog.jp

対処法3:exampleを作っておく

 どういうデータを用意すれば良いのか,その形式はどのようにすれば良いのか,などを簡単なサンプルを使って説明できるとより良いと思います.が,正直面倒だと思うので,ライブラリとして開発した上で,それを使用する実験スクリプトを全てexampleに突っ込むという構成がオススメかなと思っています.

$ tree -L 3
.
├── LICENSE
├── README.md
├── package_a
│   ├── __init__.py
│   └── hoge.py
└── examples
    └── ex1
        ├── __init__.py
        └── main.py

実際の実験スクリプトを分離しつつ,使用例を示せるので楽です.ただし,あくまでこれはオプションであり,exampleだから分かりやすくしないと!と必死になっては時間が溶けるだけなので注意しましょう.
 また,実験の再現性に重点を置いて,論文中の図表と実行されるスクリプトとを対応付けるとよりわかりやすいかも知れません.他にも,どの順で何を実行するかを示したシェルスクリプトなどがあると,どの順でコードを追いかけていけば良いかも分かってくるため,実験実施中に使用したスクリプトを併記するのも良いかも知れません.

ライセンスを明記する

ライセンスが無いとどうなる?

 GitHubの規約では,明示的にライセンスが示されていないリポジトリの扱いは以下の様になっています*3

You're under no obligation to choose a license. However, without a license, the default copyright laws apply, meaning that you retain all rights to your source code and no one may reproduce, distribute, or create derivative works from your work.

つまり,ライセンスを一切選択しない場合,

  • 通常通り著作権法が適用される
  • 全ての権利は著作者に帰属し,複製や再配布は許可されない
  • そのような状態のコードを勝手に利用することは著作権法違反となる恐れがある

ということです.当然,このような状態のリポジトリを第三者が扱うことは限り無く難しくなってしまい,公開されていて見れるのに使えない,という状況に陥ってしまいます.

OSSライセンスの種類

大雑把に分けてコピーレフトかそうでないか,という2種類があります.代表的な物をいくつかあげます.

利用者に対して「このコードを組み込んで作ったりこれ自体を改変したソフトウェアは同じく公開してください」という通知をするのが概ねコピーレフトライセンスの概要であり,そういったものを要求せず.著作者の表示のみなど軽い条件を求めるのがそれ以外のものです.詳しいことは各ライセンスについて調べてみてください.
 難しいことを考えたくない,いくらでも使ってくれ,というならMITやApache 2.0を採用すれば良いのでは無いかと思います.

ライセンスってどうやって書けば良いのか分からない

全てのファイルの先頭にコメントでライセンスを埋め込むことも出来ますが,より簡潔にするならリポジトリのルートにLICCENSEファイルを置くと良いです*4

詳細は以下のhelpをご覧ください.

help.github.com

まとめ

 OSS開発になじみのある人ならば,この辺りのことはほとんど呼吸のように行っている可能性がありますが,研究と開発は違います.その辺りは理解しているつもりですが,あまりにも使えないリポジトリと遭遇するので……
 上記の内容が守られていれば,より利用されやすくなり,引用も増えて嬉しいのではないでしょうか.よろしくお願いします.


*1:pip install tensorflowで入るバージョンを指して言っています

*2:condaはそれらも一部サポートしていますが完全ではありません

*3:ただし,Forkする権利はGitHubの規約によって依然として存在します. help.github.com

*4:パッケージングする場合はLICENSEファイルを忘れず含めるようにしてください

Pythonの新しいCLIアプリケーションフレームワークuroborosを公開した

はじめに

Pythonの全てのCLIアプリケーションフレームワークに習熟してるわけではないです.
自分が必要だと思ったものから優先的に実装しています.こうした方がいいとかあれば,リポジトリのIssueにお願いします.

背景

2019年現在,ソフトウェア系の研究をする人が避けて通れない言語がPythonと言っても過言では無くなってきました(炎上).実験でスクリプトを回すとき,パラメータ変更のために毎回ソースコードを書き換えるなんていう真似をしている人はおそらく居ないと思いますが,皆様どのようにCLIアプリケーションを構築されていますでしょうか.
Pythonの有名なCLIアプリケーションフレームワークはいくつかあります.僕の個人的な感想を併記します.

  • Click
    • ○:関数を簡単にコマンドにできるのは便利
    • ×:デコレータ地獄になっているコードを読み解きたいという気持ちには全くならない
  • Cliff
    • ○:OpenStackのサポートがあって将来的なサポートも期待できそう
    • ×:setuptoolsの力を借りないとサブコマンドを使えなくて嬉しくない
  • Python Fire
    • ○:Googleのサポートがあるので将来的なサポートも期待できそう
    • ×:これ使いやすいですか?
  • Cement
    • ×:本家ドキュメントのGetting startedを読んでこんなに使い方が分からないとは思わなかった
  • Plac
    • ×:もはやPythonを書いていない

正直どれもこれも学習コストが高いか,可読性を犠牲にしており,それなら自分でargparseのラッパーを書いた方がええわ!となりました.世間のPythonistaたちはどうしてるんでしょうか…
そもそもPythonargparseは非常に高機能で,CLIアプリケーション構築に必要な機能はほとんど網羅しているかと思います.インタラクティブシェル的な流行りの機能は難しいですが,そんな要件,普通に存在しますか?僕は幸い出会ったことがありません.というわけで,僕自身の機能に対するニーズはargparseで十分に満たされており,後はどれだけ快適に書けるか,というところだけです.長くなりましたがこういう経緯で新しく実装することにしました.

方針

  • 学習コストをできるだけ低く抑えること
  • argparseの使用感を損なわずにコマンドを構造的に書けるようにすること
  • 標準的な機能を利用する上では追加の依存を必要としないこと
  • Python3.5以上のみをサポートすること
    • もうこの世にPython2を使っているのはgcloudコマンドしか居ないため

気持ちだけGoのCLIアプリフレームワークspf13/cobraを参考にしました.

uroboros

GitHubで公開しています.現在v0.1.0です.

github.com

名前の由来

f:id:pudding_info:20190713220911p:plain
無限や不老不死の象徴として描かれる自分の尻尾を食べる蛇、ウロボロスの輪のイラスト from いらすとや

ヘビっぽい単語で使えそうなのを探していたとき,ouroborosウロボロス)は使われていましたがWikipediaを見るとuroborosでも良さそうだったのでこれにしました.
ちなみにウロボロスは自分の尾を飲み込む蛇で終わり始まりが無いこと,不老不死,循環性,無限性の象徴ですが,このフレームワークにおいてサブコマンドは再帰的に構築され,ループしているとエラーを吐くので全然名前を表していないなと作ってから思いました.

インストール

pipでインストールするだけです.今のところサードパーティーの依存パッケージはありません.

$ pip install uroboros

使い方

覚える必要があるのはargparseの使い方と,uroboros.Commanduroboros.Optionだけです.しかも大抵の要件ではuroboros.Commandだけでも十分でしょう.uroborosではuroboros.Commandを継承して作成したコマンドクラスをノードとするN分木を作るようなイメージでCLIツールを構築していきます.

# main.py
from uroboros import Command
from uroboros import ExitStatus

class RootCommand(Command):
    """アプリケーションのルートコマンド"""
    # アプリケーションのルートコマンド.
    name = 'sample'
    # アプリケーションの説明
    long_description = 'This is a sample command using uroboros'

    version = "v0.0.0"

    def build_option(self, parser):
        """
        引数の追加.
        argparse.ArgumentParserのインスタンスが引数として渡される.
        """
        parser.add_argument('--version',
                            action='store_true',
                            default=False,
                            help='Print version')
        # 追加した後にparserを返す
        return parser

    def run(self, args):
        """
        このコマンドが指定されたときに実行する内容.
        argsはargparse.Namespaceオブジェクトで引数をパースした結果.
        """
        if args.version:
            print("{name} v{version}".format(
                name=self.name, version=self.version))
        else:
            # このコマンドのヘルプテキストを出力するヘルパー関数
            self.print_help()
        # 返り値はExit Statusになる.
        # `uroboros.ExitStatus`を使っても良いし,単にintでも良い.
        return ExitStatus.SUCCESS


root = RootCommand()

if __name__ == "__main__":
    # 実行時は単にそのコマンドのexecuteを呼ぶだけ
    exit_code = root.execute()
    exit(exit_code)

これが基本的なコマンド一つの実装になります.ポイントは

  • argparse.ArgumentParserインスタンスが渡されるので自由にオプションを追加する
  • 実行時には引数がargparseによってパースされ,argparse.Namespaceインスタンスが渡されるので,自由にオプションを読み取って実行する
  • 返り値はそのまま終了ステータスになるので,intまたはuroboros.ExitStatus*1を返す

です.試しにヘルプを表示するとこうなります.

$ python main.py -h
usage: sample [-h] [--version]

This is a sample command using uroboros

optional arguments:
  -h, --help  show this help message and exit
  --version   Print version
# ちゃんとバージョン表示できる
$ python main.py --version
sample v0.0.0

ではサブコマンドを追加してみます.

# main.py
from uroboros import Command
from uroboros import ExitStatus

class RootCommand(Command):
    ...

class HelloCommand(Command):
    # サブコマンド名
    name = 'hello'
    # サブコマンド一覧を表示したときの短いメッセージ
    short_description = 'Hello world!'
    long_description = 'Print "Hello world!" to stdout'

    def run(self, args):
        print(self.short_description)
        return ExitStatus.SUCCESS

root = RootCommand()
root.add_command(HelloCommand())

...

これでhelloサブコマンドが使えるようになります.

# short descriptionはここで表示される
$ python main.py -h 
usage: sample [-h] [--version] {hello} ...

This is a sample command using uroboros

optional arguments:
  -h, --help  show this help message and exit
  --version   Print version

Sub commands:
  {hello}
    hello     Hello world!
# long descriptionはこっちで表示される
$ python main.py hello -h
usage: sample hello [-h]

Print "Hello world!" to stdout

optional arguments:
  -h, --help  show this help message and exit
# Hello worldしてみる
$ python main.py hello
Hello world!

更に複数のモジュールから複雑なコマンドを生成するサンプルはこちらにあります. これをargparseで実装する場合,(色々と省略すると)以下の様になります.

import argparse

def print_version(parser, args):
    if args.version:
        print("sample v0.0.0")
    else:
        parser.print_help()

def hello(parser, args):
    print("Hello world!")

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--version',
                        action='store_true',
                        default=False,
                        help='Print version')
    parser.set_defaults(func=print_version)
    sub_parser = parser.add_subparsers()
    hello_parser = sub_parser.add_parser('hello')
    hello_parser.set_defaults(func=hello)

    args = parser.parse_args()
    args.func(parser, args)

if __name__ == '__main__':
    main()

細かい動作が違いますが概ねこういう感じです.規模が大きくなってきたときにどうなるかは考えるまでも無いでしょう.更にサブコマンドの下にサブコマンドを…等をし始めると,あっという間にスパゲティコードの誕生です.
uroborosでは内部でadd_subparser()を使っており,実際の挙動としては上記のような挙動を全てラップしているような感じになります.

オプション引数の共有

いくつかのサブコマンドで同じオプション引数を使い回したいという需要はあると考えています*2.これを容易に実現する仕組みがuroboros.Optionです.コードの全体像はリポジトリのexampleに任せ,要点だけ説明します.このexampleでは指定されたディレクトリ内のファイルを一覧表示するfilesコマンド, ディレクトリを一覧表示するdirsコマンドを実装しています.
まずuroboros.Optionを継承したオプションクラスを作成します. uroboros.Commandと同様にbuild_optionメソッドで引数を追加できます.

from pathlib import Path
from uroboros import Option, Command

class CommonOption(Option):

    def build_option(self, parser):
        # 共通するオプションを追加する
        parser.add_argument('path', type=Path, help="Path to show")
        parser.add_argument('-a', '--absolute', default=False,
                            action='store_true',
                            help='Show absolute path')
        return parser

class RootCommand(Command):
    ...

# 共通するオプションをクラス変数`options`に設定する
class DirsCommand(Command):
    name = 'dirs'
    options = [CommonOption()]
    ...

class FilesCommand(Command):
    name = 'files'
    options = [CommonOption()]
    ...

root = RootCommand()
root.add_command(DirsCommand())
root.add_command(FilesCommand())

...

これだけでdirsサブコマンドとfilesサブコマンドでpathという位置引数と--absoluteというオプション引数が使えるようになります.
このOptionの良いところは単にくくりだして記述量を減らせるだけでなく,値のバリデーションをまとめることが出来る点にあります.

...

class CommonOption(Option):

    def build_option(self, parser):
        ...

    # 実行時にCommandの`run`より先に呼ばれる
    def validate(self, args):
        # 返すのはExceptionのリスト.バリデーション違反がない場合,空のリストを返す.
        errors = []
        # 指定したパスが存在しない場合エラーにする
        if not args.path.exists():
            errors.append(Exception("'{}' does not exists.".format(args.path)))
        return errors

...

これで例えば存在しないパスを指定すると,filesサブコマンドでもdirsサブコマンドでも共通して存在しないパスを弾くことが出来ます.

$ python main.py files /does/not/exists
'/does/not/exists' does not exists.
$ python main.py dirs /does/not/exists
'/does/not/exists' does not exists.

注意点

良い意味でも悪い意味でのargparseのラッパーに過ぎないため,コマンドライン引数のパースは完全にargparse任せになっています.このため,現状ではサブコマンド以下の引数の名前と,親コマンドで使用している引数の名前がコンフリクトすると最後に設定したオプションで上書きされてしまいます.例えば

$ python main.py root -d fuga command1 -d poyo

のような仕様にした場合,最終的には後者の-d poyoで上書きされてしまいます.これは

  1. 親コマンドの引数のパースをする
  2. サブコマンド以下の引数だけ抽出
  3. サブコマンドの引数のパースをする

の順で解決することでうまく行くはずですが,引数へのアクセシビリティが下がるため避けています.
つまり,今はargs.hogeargs.fugaでアクセスできるコマンドライン変数に対しサブコマンドごとに名前空間を区切ると,args.subcommand.hogeみたいなアクセスの仕方になってしまい,親コマンドからの相対的な位置に依存することになり,せっかく全てのコマンドが個別でexecute()できる仕様が崩壊してしまいます.現状のコマンドの引数のみにフォーカスして渡すことも出来ますが,上位コマンドのオプション引数を解決できなくなってしまいます.
これについては今後の展望で述べるHook機能を使うことで一部解消できるはずです.各コマンドのオプションは各コマンドごとにパースし,必要な処理はHookで行うことで,最終的に実行されるCommandのrunメソッドでは常にグローバルな名前空間にアクセスする必要がなくなる(親コマンドのオプション引数はその親コマンドのHookで解消する)ためです.が,逆に上位コマンドのオプションにはアクセスできなくなるため,上手い仕組みが必要だと考えています.

今後の展望

  • 各コマンドにHookを用意する
    • 例えばルートコマンドにHookを仕掛けてアプリケーション全体でのloggerの設定をするなどに使う
  • バリデーションエラーハンドラーのサポート
    • 現状ではエラーが発生したらそのままlogger.error()して終了してしまう.
    • バリデーションからの回復を可能にするなどがあると便利そう.
  • add_commandで複数のCommandインスタンスを受け取れるようにする
    • すぐできるが需要があるのか分からない
  • オプションをファイルから読み取り/ファイルへ保存
    • json以外の形式のサポートはサードパーティーの依存パッケージを追加することになるので,やるとしてもオプション扱い.
    • あまり上手い形式を思いついていない

他にも要望や改善等あれば是非お願いします.

まとめ

argparseについての知識を活かしたまま,よりCLIアプリケーションを構築しやすくするためのフレームワークとしてuroborosを作成・公開しました.
これにより,サブコマンドの数の増加・そのネスト数の増加に伴って複雑化しがちなCLIアプリケーションを比較的簡単に構築することができるようになりました.

何かフィードバック等あれば遠慮無く頂けると幸いです.よろしくお願いします.

*1:IntEnumで定義されているので実質intです

*2:少なくとも僕にはありました

英文を一文ずつに分割してGoogle翻訳へ投げるAlfred Workflowを作った

これまでに作ったAlfred Workflow

poyo.hatenablog.jp

poyo.hatenablog.jp

これは何?

一文ごとに分解して翻訳にかけることで見通しが良くなり,論文を読む速度が上がるWorkflow.

f:id:pudding_info:20190606222320g:plain

サンプルとして使用したのは,機械翻訳ネットワークTransformerを提案した論文*1

論文PDFの問題

全てがそうではありませんが,論文PDFはたまにそのままコピーすると「見かけ上の改行位置がそのまま反映された文章」としてコピーされてしまうことがあります*2.例えば上の例で用いた論文も,Abstractをそのままコピペすると見た目上の改行位置のままコピペされてしまいました.

f:id:pudding_info:20190606223240p:plain
そのままコピペした場合

この場合,文章の途中で改行されてしまうことが多いため,翻訳もめちゃくちゃになってしまいます.

環境

  • macOS 10.14.5
  • Alfred 4.0.1
  • Go 1.12.5

今回もGoを使っているので,使用に当たって特に準備するものはありません.今回に限っては特にGoである必要もありませんでしたが,楽だったので採用しています.
Alfredの最新版4.0系にも対応しています.3系でも動作を確認しています.

Google Translate Formatter

github.com

準備

最新版は以下からダウンロードできます.

Releases · pddg/alfred-google-translate-formatter-workflow · GitHub

AlfredとPowerpack*3が有効になっていれば,ダウンロードしたファイルを開くだけでインストールするウィンドウが出てくるはずです.
使用するためにはインストール後,ショートカットキーを設定する必要があります.

f:id:pudding_info:20190606224842p:plain

使い方

  1. 翻訳にかけたい文章を選択する
  2. ショートカットを押す
  3. ブラウザが開く

アプリケーションを問わず動作するため,ショートカットキーのコンフリクトにはお気を付けください.
これは逆に言うとブラウザ,PDFビューワ,エディタ等何にでも使えます.man コマンドのhelpでも使えます.

f:id:pudding_info:20190606225356g:plain

注意点

一文ごとに分解する機能は単なる正規表現マッチとIFの羅列

ソースコードを見れば分かりますが,単なる正規表現で一文を区切っています.さすがにピリオドの位置で分割は頭が悪すぎるので,ピリオドの次に来る文字が英語の大文字であれば改行,というようなことをしています.

Hoge fuga piyo
poyo. Poe poe.

↓

Hoge fuga piyo poyo.

Poe poe.

そのため,1.234のような浮動小数点数のピリオドは改行としては認識されません.逆に言うと,文頭が数字であれば改行としては認識されません.

Hoge fuga 1.234 piyo
poyo. 1 poe poe.

↓

Hoge fuga 1.234 piyo poyo. 1 poe poe.

ピリオドが無い場合は文字列分解できない.

例えば論文中でも,箇条書き等で文末にピリオドが無い場合,文章の終わりを認識できないため,改行されません.

Hoge fuga piyo poyo
Poe poe

↓

Hoge fuga piyo poyo Poe poe

意図的に混入されているハイフンを消してしまう

論文ではよく,改行位置に長い英単語が来た場合,その単語の途中にハイフンを挟んで改行することがあります.この場合に正しく英単語を解釈できるようにするため,改行直前のハイフンを除去して後続の単語と結合するという処理を挟んでいます.これは便利な機能ではありますが,意図的にハイフンを加えられている場合でも削除されてしまうことに注意が必要です.

Hoge fuga piyo-
poyo. Poe poe.

↓

Hoge fuga piyopoyo.

Poe poe.

たまにうまく動かない

ショートカットを押しても,うまくコピーした内容が伝播されず,エラーが出る場合があります.その場合,以下の様な通知が出ますので,選択し直す等でリトライしてみてください.

f:id:pudding_info:20190606230548p:plain

まとめ

もうかれこれ2年近く使っていて知り合いも便利に使ってくれているので,ちょっと整理してgo moduleに対応して供養しておくことにしました.
もう少し良い文章の分割アルゴリズム等があれば,教えて頂けると嬉しいです.いつでもcontributionお待ちしています.

2019/8/16 追記

知らない間にすごく伸びていて驚きました.使って頂いて色んなフィードバックを貰えると嬉しいです.はてブのコメントも読んでいるのでいくつかお答えしておきます.

  • Google Traslate API使った方が良いのでは?
    Google Translate APIの使用のためにはGoogle Cloudへの登録が必要になってきます.料金は良心的ですが,設定が煩雑になる上,(ご指摘の通り)結局どこに表示するかという問題を解決できません.Google翻訳のUIにより表示後にも微調整が可能なため,柔軟性が良くなっていると感じています.
  • ヘッダやフッタのページ数などの文字列もコピーされてしまうのを除外してくれるといい
    これは箇条書きと同じようにただのピリオドの無い文字列として認識されるため,現状対応が非常に難しいです.論文PDFから本文だけ抽出できるようなツールがあれば組み合わせて使えるかも知れませんが…
  • OmegaTっていうのがあるよ
    これは純粋に知りませんでした.が,求めているものとは少し違うなと感じています.これでやりたいのはざっくりした意味の把握なので,精読したり完全な和訳を生成することが目的ではありません.

いくらでもPull Request,Issue歓迎しておりますので機能への不満やバグ等あれば遠慮無くリポジトリの方へよろしくお願いします.

*1:Vaswani, A., Brain, G., Shazeer, N., Parmar, N., Uszkoreit, J., Jones, L., … Polosukhin, I. (n.d.). Attention Is All You Need. Retrieved from https://arxiv.org/pdf/1706.03762v5.pdf

*2:これはビューワ等に依存するのかもしれません.よくわかりませんが,手元では発生する場合,発生しない場合があるようでした

*3:Alfred Workflowを使用可能にするための課金パッケージ