---
title: pytestでRust製CLIをe2eテストしてみよう / PyCon mini 東海 2024
tags:  #sphinx #python  
author: [Kazuya Takei](https://convert.docswell.com/user/attakei)
site: [Docswell](https://www.docswell.com/)
thumbnail: https://bcdn.docswell.com/page/GEWP3282J2.jpg?width=480
description: PyCon mini 東海 2024で発表したスライドのPDF版
published: November 23, 24
canonical: https://convert.docswell.com/s/attakei/59VRPY-pycon-mini-tokai-2024
---
# Page. 1

![Page Image](https://bcdn.docswell.com/page/GEWP3282J2.jpg)

pytestでRust製CLIをe2eテストしてみよう
author:
Kazuya Takei / @attakei
date:
2024/11/16
event:
PyCon mini 東海 2024
hashtags:
#pycontokai


# Page. 2

![Page Image](https://bcdn.docswell.com/page/47ZKVD8473.jpg)

はじめに


# Page. 3

![Page Image](https://bcdn.docswell.com/page/YJ6Q51PGJV.jpg)

このトークの概要
このトークでは、
PythonistaがRustでCLIを作った際に
試行錯誤の結果として
品質担保をRustではなくPythonで実施した
という話をします。


# Page. 4

![Page Image](https://bcdn.docswell.com/page/GJ5RY4KX74.jpg)

このトークの概要
このトークでは、
Rust側の細かい話
作ったCLI自体の細かい話
「超テクニカル」な使い方
という話はしません。
（考え方とエッセンシャルな技法が中心です）


# Page. 5

![Page Image](https://bcdn.docswell.com/page/9E2DGVWQER.jpg)

お前誰よ
Kazuya Takei
attakei (X, GitHub, etc)
株式会社ニジボックス
趣味系Pythonista &lt;= こっち
ライブラリ・拡張系を作りがち
Sphinx拡張生成マシーン
Sphinxでプレゼンテーションしたがる人
Python は 2.6ぐらいから？
（GAE for Python出たあたり）


# Page. 6

![Page Image](https://bcdn.docswell.com/page/D7Y2V3LY7M.jpg)

株式会社ニジボックス
https://www.nijibox.jp
ニジボックス は「Grow all」をミッションに、企業やサービスの成長に向き合
い続けるリクルートグループのデザイン会社です。
お客様のビジネスの成長をUI UXのデザインプロセスから開発・運用・改善まで
ワンストップでサポートします。


# Page. 7

![Page Image](https://bcdn.docswell.com/page/VEN8MV4RJ8.jpg)

株式会社ニジボックス
POSTD
https://postd.cc
エンジニアに向けたキュレーションメディア
海外のテック記事を日本語に翻訳して配信


# Page. 8

![Page Image](https://bcdn.docswell.com/page/Y79KN6QZ73.jpg)

本日の脇役: age-cli


# Page. 9

![Page Image](https://bcdn.docswell.com/page/G78RKZQYED.jpg)

age-cliの宣伝
ライブラリのバージョニングを補助するCLIツール。Rust製。
管理用ファイルに「現在のバージョン」と「更新対象とルール」を記述。
age mijor|minor|patch で一括でルールに基づいて更新。
今のところ、コミットなどはしない。


# Page. 10

![Page Image](https://bcdn.docswell.com/page/L7LKZDX9ER.jpg)

age-cliの宣伝
.age.toml(一部略)
# バージョン情報
current_version = &quot;0.7.0&quot;
#[[files]]
更新対象（いっぱい）
path = &quot;Cargo.toml&quot;
search = &quot;version = \&quot;{{current_version}}\&quot;&quot;
replace = &quot;version = \&quot;{{new_version}}\&quot;&quot;
[[files]]
path = &quot;CHANGELOG.md&quot;
search = &quot;# Changelog&quot;
replace = &quot;&quot;&quot;
# Changelog
## v{{new_version}} - {{now|date}} (JST)
&quot;&quot;&quot;


# Page. 11

![Page Image](https://bcdn.docswell.com/page/4EM2VPLVEW.jpg)

age-cliの宣伝
実行例
$ time age minor
Updated!
age minor 0.00s user 0.00s system 90% cpu 0.003 total
$ git status
On branch main
Your branch is up to date with &#039;origin/main&#039;.
Changes
not
staged
for
commit:
(use &quot;git add &lt;file&gt;...&quot; to update what will be committed)
(use &quot;git restore &lt;file&gt;...&quot; to discard changes in working directory)
modified: .age.toml
modified: .github/release-body.md
modified:
CHANGELOG.md
modified: Cargo.toml
modified: doc/conf.py
modified: doc/usage/installation.rst


# Page. 12

![Page Image](https://bcdn.docswell.com/page/PERM21KW79.jpg)

(余談)
Q: なんで作ったんですか？
A1: 似たPythonライブラリがあるけど、世代交代についていけなくなっ
た。
bumpversion
bump2version
bump-my-version
A2: せっかくなので、Rustの習作にしたかった。


# Page. 13

![Page Image](https://bcdn.docswell.com/page/P7XVDYLVJX.jpg)

Pythonに浸かった人から見たRust
※個人の感想です


# Page. 14

![Page Image](https://bcdn.docswell.com/page/37KQX3L57D.jpg)

Rustの良いところ
最終生成物の動作速度が軽快。
バイナリ配布がしやすい。
LSPもlinterもRustの公式Orgが提供しており、
「とりあえずこれ」がしやすい。
本体ソースにテストコードを同居させられる。
コンパイルエラーがあるから（？）、
不安定なコードを潰しやすい ※ただし体感しづらい


# Page. 15

![Page Image](https://bcdn.docswell.com/page/LJ3P5G3175.jpg)

Rustの辛いところ
いくつかの要素に慣れるまでが大変。
「借用」まわり。
データ構造に柔軟性がない。
標準ライブラリがそんなに多くない
↓ありそうでないもの（クレートはあるのでなんとかなる）
regex
toml


# Page. 16

![Page Image](https://bcdn.docswell.com/page/8JDLD14K7G.jpg)

(テストの一例)
// モジュール内の関数
pub fn up_major(base: &amp;Version) -&gt; Version {
Version {
major: base.major + 1,
minor: 0,
patch: 0,
pre:
Prerelease::EMPTY,
build: BuildMetadata::EMPTY,
}
}
//#[cfg(test)]
ここから先が↑のテスト（同一コード内）
mod tests {
use super::*;


# Page. 17

![Page Image](https://bcdn.docswell.com/page/VEP2XQM3E8.jpg)

(テストの一例)
関数等のユニットテスト自体は頑張れる。
習作フェーズで網羅するのしんどい。（というか面倒）


# Page. 18

![Page Image](https://bcdn.docswell.com/page/27VDL59N7Q.jpg)

要点
Pythonistaが動作速度などを求めてRustに手を出し
LSPなどを駆使して「やりたいこと」の実現ぐらいにはたどり着けたが
「動作の担保」までRustで頑張るまでが大変


# Page. 19

![Page Image](https://bcdn.docswell.com/page/5JGYX6Z5JL.jpg)

要点
Pythonistaが動作速度などを求めてRustに手を出し
LSPなどを駆使して「やりたいこと」の実現ぐらいにはたどり着けたが
「動作の担保」までRustで頑張るまでが大変
→ここはRustなくても平気なのでは？


# Page. 20

![Page Image](https://bcdn.docswell.com/page/47QM84LLEP.jpg)

要点
Pythonistaが動作速度などを求めてRustに手を出し
LSPなどを駆使して「やりたいこと」の実現ぐらいにはたどり着けたが
「動作の担保」までRustで頑張るまでが大変
→ここはRustなくても平気なのでは？
→じゃあ、pytest使うか！


# Page. 21

![Page Image](https://bcdn.docswell.com/page/KE4V2QD571.jpg)

テストツールとしてのpytest


# Page. 22

![Page Image](https://bcdn.docswell.com/page/L713MPZ2EG.jpg)

pytest
Pythonのテストライブラリ。
関数とassertというシンプルな作りが基本。
fixtureという仕組みが便利（後述）。
標準ライブラリにunittestはあるが、こっちの
ほうがデファクト感ある。


# Page. 23

![Page Image](https://bcdn.docswell.com/page/G7WP3292E2.jpg)

サンプルコード
1 def test_foo():
2 # 成功する
34 assert True is True
5
6 def test_bar():
7 # 失敗する
89 assert &quot;Hello&quot; == &quot;hello&quot;
10
11 def test_buzz(capsys): # 標準出力をテストする
12 print(&quot;Hello world&quot;)
1314 cassert
= capsys.readourerr()
c.out.startswith(&quot;Hello&quot;)


# Page. 24

![Page Image](https://bcdn.docswell.com/page/4JZKVD94J3.jpg)

サンプルコード
1 def test_foo():
2 # 成功する
34 assert True is True
5
6 def test_bar():
7 # 失敗する
89 assert &quot;Hello&quot; == &quot;hello&quot;
10
11 def test_buzz(capsys): # 標準出力をテストする
12 print(&quot;Hello world&quot;)
1314 cassert
= capsys.readourerr()
c.out.startswith(&quot;Hello&quot;)


# Page. 25

![Page Image](https://bcdn.docswell.com/page/YE6Q51KGEV.jpg)

サンプルコード
1 def test_foo():
2 # 成功する
34 assert True is True
5
6 def test_bar():
7 # 失敗する
89 assert &quot;Hello&quot; == &quot;hello&quot;
10
11 def test_buzz(capsys): # 標準出力をテストする
12 print(&quot;Hello world&quot;)
1314 cassert
= capsys.readourerr()
c.out.startswith(&quot;Hello&quot;)


# Page. 26

![Page Image](https://bcdn.docswell.com/page/GE5RY4PXJ4.jpg)

サンプルコード
1 def test_foo():
2 # 成功する
34 assert True is True
5
6 def test_bar():
7 # 失敗する
89 assert &quot;Hello&quot; == &quot;hello&quot;
10
11 def test_buzz(capsys): # 標準出力をテストする
12 print(&quot;Hello world&quot;)
1314 cassert
= capsys.readourerr()
c.out.startswith(&quot;Hello&quot;)


# Page. 27

![Page Image](https://bcdn.docswell.com/page/972DGV6Q7R.jpg)

pytestの良いところ
関数ベースでのテストが基本のため、インデントが浅い。
TestCaseクラスのようなものは不要。（使うことは出来る）
ドキュメントがちゃんとしてるので、自分での機能追加もしやすい。
↑の実装が規約ベースで書けるので、考えることがあまり多くない（は
ず）。


# Page. 28

![Page Image](https://bcdn.docswell.com/page/DJY2V39YJM.jpg)

fixture
テストのプロセスに割り込んだりするための仕組み。
多くは、「各テストの引数」の体裁で、 テストの前処理をオブジェクトを
受け取ってテストする。
受け取った中身はオブジェクトなので、メソッドなどを駆使して高度なこ
とも出来る。
scopeの概念があり処理の差し込みは、かなり柔軟。
各テストの前/後
テストモジュール全体の前/後
テスト実行全体の最初/最後


# Page. 29

![Page Image](https://bcdn.docswell.com/page/V7N8MVLRE8.jpg)

e2eテストを頑張るためのpytest


# Page. 30

![Page Image](https://bcdn.docswell.com/page/YJ9KN64ZJ3.jpg)

e2eとしてのpytest
「Pythonのモジュール内動作確認」としては使わない。
「Pythonを使ったコマンド実行結果の検証」のラッパーとして使う。
「FastAPIのWebアプリ開発時にTestClientを使う」あたりと近い。


# Page. 31

![Page Image](https://bcdn.docswell.com/page/GJ8RKZVY7D.jpg)

【NOTE】ここから先の各要素は、相互依存的な内容です。


# Page. 32

![Page Image](https://bcdn.docswell.com/page/LJLKZD597R.jpg)

parameterize
pytest組み込みのフィクスチャ。
テスト関数へのデコレーターとして使う。
引数に従って、デコレーションしているテスト関数を複数パターンで実行
できる。
とにかく同じコマンドを繰り返すので、無いと困る存在。


# Page. 33

![Page Image](https://bcdn.docswell.com/page/47M2VP3V7W.jpg)

parameterize
よく見る使い方
1 import pytest
2
3
45 @pytest.mark.parametrize(
&quot;in_,out_&quot;,
# 第1引数で、テスト関数に渡す名称を決めて
6 [(2, 4), (3, 9)] # 第2引数で、渡したい値をリストで定義する
7)
8 def test_it(in_, out_):
9
out
=
in_
*
in_
10 assert out == out_


# Page. 34

![Page Image](https://bcdn.docswell.com/page/P7RM21QWJ9.jpg)

parameterize
よく見る使い方
1 import pytest
2
3
45 @pytest.mark.parametrize(
&quot;in_,out_&quot;,
# 第1引数で、テスト関数に渡す名称を決めて
6 [(2, 4), (3, 9)] # 第2引数で、渡したい値をリストで定義する
7)
8 def test_it(in_, out_):
9
out
=
in_
*
in_
10 assert out == out_


# Page. 35

![Page Image](https://bcdn.docswell.com/page/PJXVDY5VEX.jpg)

parameterize
よく見る使い方
1 import pytest
2
3
45 @pytest.mark.parametrize(
&quot;in_,out_&quot;,
# 第1引数で、テスト関数に渡す名称を決めて
6 [(2, 4), (3, 9)] # 第2引数で、渡したい値をリストで定義する
7)
8 def test_it(in_, out_):
9
out
=
in_
*
in_
10 assert out == out_


# Page. 36

![Page Image](https://bcdn.docswell.com/page/3JKQX365JD.jpg)

parameterize
&lt;age-cli内での使い方&gt;
1 def get_env_dirs(name: str):
2 # name 配下のサブフォルダを一括でテストケース化する
3 paths = [p for p in (root / name).glob(&quot;*&quot;) if p.is_dir()]
45 return&quot;argvalues&quot;:
{
paths,
6
&quot;ids&quot;: [f&quot;{p.parent.name}/{p.name}&quot; for p in paths]
7 }
8
9
10 @pytest.mark.parametrize(
11 &quot;env_path&quot;,
# 第1引数の使い方は同じ
12 **get_env_dirs(&quot;return-1&quot;), # 可変長引数を使って、その場でリストを作る
13 )
1415 def ...
test_invalid_env(cmd, env_path: Path, tmp_path: Path):


# Page. 37

![Page Image](https://bcdn.docswell.com/page/LE3P5G61J5.jpg)

parameterize
&lt;age-cli内での使い方&gt;
1 def get_env_dirs(name: str):
2 # name 配下のサブフォルダを一括でテストケース化する
3 paths = [p for p in (root / name).glob(&quot;*&quot;) if p.is_dir()]
45 return&quot;argvalues&quot;:
{
paths,
6
&quot;ids&quot;: [f&quot;{p.parent.name}/{p.name}&quot; for p in paths]
7 }
8
9
10 @pytest.mark.parametrize(
11 &quot;env_path&quot;,
# 第1引数の使い方は同じ
12 **get_env_dirs(&quot;return-1&quot;), # 可変長引数を使って、その場でリストを作る
13 )
1415 def ...
test_invalid_env(cmd, env_path: Path, tmp_path: Path):


# Page. 38

![Page Image](https://bcdn.docswell.com/page/8EDLD1VKJG.jpg)

parameterize
&lt;age-cli内での使い方&gt;
1 def get_env_dirs(name: str):
2 # name 配下のサブフォルダを一括でテストケース化する
3 paths = [p for p in (root / name).glob(&quot;*&quot;) if p.is_dir()]
45 return&quot;argvalues&quot;:
{
paths,
6
&quot;ids&quot;: [f&quot;{p.parent.name}/{p.name}&quot; for p in paths]
7 }
8
9
10 @pytest.mark.parametrize(
11 &quot;env_path&quot;,
# 第1引数の使い方は同じ
12 **get_env_dirs(&quot;return-1&quot;), # 可変長引数を使って、その場でリストを作る
13 )
1415 def ...
test_invalid_env(cmd, env_path: Path, tmp_path: Path):


# Page. 39

![Page Image](https://bcdn.docswell.com/page/V7P2XQV378.jpg)

tmp_path
pytest組み込みのフィクスチャ。
「一時ファイル置き場」となるディレクトリを生成してくれる。
CLIの実行時に &quot;working directory&quot;として使用できる。
これを使って、「コマンドの実行前後」の想定状態を再現テストしてい
る。


# Page. 40

![Page Image](https://bcdn.docswell.com/page/2JVDL5ZNJQ.jpg)

tmp_path
&lt;age-cli内での使い方&gt;
1 import shutil
2
3 @pytest.mark.parametrize(
45 **get_env_dirs(&quot;return-1&quot;),
&quot;env_path&quot;,
6 )
7 def test_invalid_env(cmd, env_path: Path, tmp_path: Path):
8 &quot;&quot;&quot;Run test cases on env having invalid configuration.&quot;&quot;&quot;
9
#
生成されている一時フォルダに、まるっとテスト用ファイルをコピーしている
10 shutil.copytree(env_path / &quot;before&quot;, tmp_path, dirs_exist_ok=True)
11 ...


# Page. 41

![Page Image](https://bcdn.docswell.com/page/5EGYX645EL.jpg)

tmp_path
&lt;age-cli内での使い方&gt;
1 import shutil
2
3 @pytest.mark.parametrize(
45 **get_env_dirs(&quot;return-1&quot;),
&quot;env_path&quot;,
6 )
7 def test_invalid_env(cmd, env_path: Path, tmp_path: Path):
8 &quot;&quot;&quot;Run test cases on env having invalid configuration.&quot;&quot;&quot;
9
#
生成されている一時フォルダに、まるっとテスト用ファイルをコピーしている
10 shutil.copytree(env_path / &quot;before&quot;, tmp_path, dirs_exist_ok=True)
11 ...


# Page. 42

![Page Image](https://bcdn.docswell.com/page/4JQM84GL7P.jpg)

pytest_sessionstart
conftest.py にこの関数を定義すると、 「pytestのセッション開始時」=
「pytest実行の最初に1回」特定の処理を実行できる。
テスト対象のビルドや、共有環境のクリーンアップに向いている。
実際に age 本体のビルドをここでしている。


# Page. 43

![Page Image](https://bcdn.docswell.com/page/K74V2Q55J1.jpg)

pytest_sessionstart
&lt;age-cli内での使い方&gt;
1 def pytest_sessionstart(session):
2 &quot;&quot;&quot;Generate age binary for testing.&quot;&quot;&quot;
3 print(&quot;Now building binary from Cargo ... &quot;, end=&quot;&quot;)
45 proc[&quot;cargo&quot;,
= run( &quot;build&quot;],
6
stdout=PIPE, stderr=PIPE, cwd=project_root
7 )
8 if proc.returncode != 0: # 万が一ビルドに失敗したら「e2eしない」ためにexit
9
print(&quot;Failed!!&quot;)
pytest.exit(1)
10
11 print(&quot; OK!&quot;)


# Page. 44

![Page Image](https://bcdn.docswell.com/page/LJ13MPW27G.jpg)

pytest_sessionstart
&lt;age-cli内での使い方&gt;
1 def pytest_sessionstart(session):
2 &quot;&quot;&quot;Generate age binary for testing.&quot;&quot;&quot;
3 print(&quot;Now building binary from Cargo ... &quot;, end=&quot;&quot;)
45 proc[&quot;cargo&quot;,
= run( &quot;build&quot;],
6
stdout=PIPE, stderr=PIPE, cwd=project_root
7 )
8 if proc.returncode != 0: # 万が一ビルドに失敗したら「e2eしない」ためにexit
9
print(&quot;Failed!!&quot;)
pytest.exit(1)
10
11 print(&quot; OK!&quot;)


# Page. 45

![Page Image](https://bcdn.docswell.com/page/GJWP326272.jpg)

pytest_sessionstart
&lt;age-cli内での使い方&gt;
1 def pytest_sessionstart(session):
2 &quot;&quot;&quot;Generate age binary for testing.&quot;&quot;&quot;
3 print(&quot;Now building binary from Cargo ... &quot;, end=&quot;&quot;)
45 proc[&quot;cargo&quot;,
= run( &quot;build&quot;],
6
stdout=PIPE, stderr=PIPE, cwd=project_root
7 )
8 if proc.returncode != 0: # 万が一ビルドに失敗したら「e2eしない」ためにexit
9
print(&quot;Failed!!&quot;)
pytest.exit(1)
10
11 print(&quot; OK!&quot;)


# Page. 46

![Page Image](https://bcdn.docswell.com/page/4EZKVDY4E3.jpg)

subprocess.run
※pytestではなく、Pythonの標準ライブラリ
おなじみ、外部コマンドを実行して結果を受け取る関数。
コマンド+引数を渡せる。
リターンコード、標準出力、標準エラーを受け取れる。
「End-to-End」の要と言える存在。


# Page. 47

![Page Image](https://bcdn.docswell.com/page/Y76Q51DG7V.jpg)

subprocess.run
&lt;age-cli内での使い方&gt;
1 def test_valid_env(cmd, env_path: Path, tmp_path: Path):
2 &quot;&quot;&quot;Run test cases on env having valid files.&quot;&quot;&quot;
3 # 環境用意
45 shutil.copytree(env_path
/
&quot;before&quot;,
tmp_path,
dirs_exist_ok=True)
# cmd(fixture製の関数で、内部でrunしてる)でage-cliを実行
6 proc: CompletedProcess = cmd(&quot;update&quot;, &quot;0.2.0&quot;)
7 assert proc.returncode == 0
8 # run()でdiffを実行して、「差分がないこと」を検証
9
#
diff
は差分がまったくないときだけリターンコードが0になる
10 diff = run([
11
&quot;diff&quot;, &quot;--recursive&quot;,
12
str(tmp_path), str(env_path / &quot;after&quot;)
13 ])
14 assert diff.returncode == 0


# Page. 48

![Page Image](https://bcdn.docswell.com/page/G75RY43XE4.jpg)

subprocess.run
&lt;age-cli内での使い方&gt;
1 def test_valid_env(cmd, env_path: Path, tmp_path: Path):
2 &quot;&quot;&quot;Run test cases on env having valid files.&quot;&quot;&quot;
3 # 環境用意
45 shutil.copytree(env_path
/
&quot;before&quot;,
tmp_path,
dirs_exist_ok=True)
# cmd(fixture製の関数で、内部でrunしてる)でage-cliを実行
6 proc: CompletedProcess = cmd(&quot;update&quot;, &quot;0.2.0&quot;)
7 assert proc.returncode == 0
8 # run()でdiffを実行して、「差分がないこと」を検証
9
#
diff
は差分がまったくないときだけリターンコードが0になる
10 diff = run([
11
&quot;diff&quot;, &quot;--recursive&quot;,
12
str(tmp_path), str(env_path / &quot;after&quot;)
13 ])
14 assert diff.returncode == 0


# Page. 49

![Page Image](https://bcdn.docswell.com/page/9J2DGVZQJR.jpg)

subprocess.run
&lt;age-cli内での使い方&gt;
1 def test_valid_env(cmd, env_path: Path, tmp_path: Path):
2 &quot;&quot;&quot;Run test cases on env having valid files.&quot;&quot;&quot;
3 # 環境用意
45 shutil.copytree(env_path
/
&quot;before&quot;,
tmp_path,
dirs_exist_ok=True)
# cmd(fixture製の関数で、内部でrunしてる)でage-cliを実行
6 proc: CompletedProcess = cmd(&quot;update&quot;, &quot;0.2.0&quot;)
7 assert proc.returncode == 0
8 # run()でdiffを実行して、「差分がないこと」を検証
9
#
diff
は差分がまったくないときだけリターンコードが0になる
10 diff = run([
11
&quot;diff&quot;, &quot;--recursive&quot;,
12
str(tmp_path), str(env_path / &quot;after&quot;)
13 ])
14 assert diff.returncode == 0


# Page. 50

![Page Image](https://bcdn.docswell.com/page/DEY2V3RYEM.jpg)

全部まとめると
テスト用の環境をフォルダ単位で管理して
session_startでCLIを事前ビルドして
parametrizeで大量のテストパターンを実行して
内部では、tmp_pathで都度きれいな環境を用意して
subprocess.runを使って、テスト結果を検査する


# Page. 51

![Page Image](https://bcdn.docswell.com/page/VJN8MVDR78.jpg)

おわりに


# Page. 52

![Page Image](https://bcdn.docswell.com/page/YE9KN6GZE3.jpg)

大事なこと
Rust製CLIの大半のテストをRustではなくPythonで書いてました。


# Page. 53

![Page Image](https://bcdn.docswell.com/page/GE8RKZ8YJD.jpg)

大事なこと
Rust製CLIの大半のテストをRustではなくPythonで書いてました。
=&gt;
メイン言語が変わっても、Pythonの使いどころが結構ある。
今回のトークだとRust製ツールをテストしたが、別にGolangでも同じこ
と。
技術トレンドが変わっても、覚えたことはいつでも選択肢になる。
Python楽しいですね。


# Page. 54

![Page Image](https://bcdn.docswell.com/page/LELKZDR9JR.jpg)

Thanks
NIJIBOX Co., Ltd.
Reveal.js
Sphinx+翻訳 Hack-a-thon
GitHubの各サービス


