Python for Windows: インターネット接続を制限された環境ではpoetryがつかえない

pipはつかえる

このあいだ、インターネット接続を制限された職場のネット環境でもpipを使う方法を見つけた、という記事を書いた。少し時間が開いてしまったけど、今日試してみた。結果は「pipはつかえる」だ。

検証のために、インストールしてあった Python をいったん削除(キャッシュやらなんやらも全部削除)して、インストールしなおした。まぁ、バージョンアップもかねて、ね。

username@workpc: Documents > python -V
Python 3.9.7

この状態ではパッケージは2つしかインストールされてない。

username@workpc: Documents > pip list
Package    Version
---------- -------
pip        21.2.3
setuptools 57.4.0

で、PyPI.org にアクセスせずローカルなネットワークで完結するよう、pip の設定ファイル %APPDATA%\Roming\pip\pip.ini を書いておく。

[global]
find-links = file://workpc/py-packages

[install]
find-links = file://workpc/py-packages
no-index = yes

[list]
no-index = yes

このあいだの記事に書いたように、--find-links オプションを指定する代わりにここに書いておく。ここでは file://workpc/py-packages を指定している。no-index = yes は PyPI.org を見に行かないようにする設定。これがないと繋がらない PyPI.org にアクセスしようとして時間を無駄にすることになる。

つぎは上の find-links で指定した共有フォルダを作る。といっても自分の PC にテキトーなフォルダを作って py-packages って名前で共有するだけだ。あとはあらかじめダウンロードしておいたパッケージファイルを放り込んでおく。

さて、これで準備は完了。試しに poetry をインストールしてみた。

username@workpc: Documents > pip install poetry
Looking in links: file://workpc/py-packages
Processing \\workpc\py-packages\poetry-1.1.11-py2.py3-none-any.whl
Processing \\workpc\py-packages\html5lib-1.1-py2.py3-none-any.whl
Processing \\workpc\py-packages\keyring-21.8.0-py3-none-any.whl
Processing \\workpc\py-packages\shellingham-1.4.0-py2.py3-none-any.whl
Processing \\workpc\py-packages\tomlkit-0.7.2-py2.py3-none-any.whl
Processing \\workpc\py-packages\clikit-0.6.2-py2.py3-none-any.whl
Processing \\workpc\py-packages\requests_toolbelt-0.9.1-py2.py3-none-any.whl
Processing \\workpc\py-packages\cleo-0.8.1-py2.py3-none-any.whl
Processing \\workpc\py-packages\poetry_core-1.0.7-py2.py3-none-any.whl
Processing \\workpc\py-packages\cachy-0.3.0-py2.py3-none-any.whl
Processing \\workpc\py-packages\requests-2.26.0-py2.py3-none-any.whl
Processing \\workpc\py-packages\virtualenv-20.8.1-py2.py3-none-any.whl
Processing \\workpc\py-packages\crashtest-0.3.1-py3-none-any.whl
Processing \\workpc\py-packages\pexpect-4.8.0-py2.py3-none-any.whl
Processing \\workpc\py-packages\cachecontrol-0.12.6-py2.py3-none-any.whl
Processing \\workpc\py-packages\pkginfo-1.7.1-py2.py3-none-any.whl
Processing \\workpc\py-packages\packaging-20.9-py2.py3-none-any.whl
Processing \\workpc\py-packages\msgpack-1.0.2-cp39-cp39-win_amd64.whl
Processing \\workpc\py-packages\lockfile-0.12.2-py2.py3-none-any.whl
Processing \\workpc\py-packages\pylev-1.4.0-py2.py3-none-any.whl
Processing \\workpc\py-packages\pastel-0.2.1-py2.py3-none-any.whl
Processing \\workpc\py-packages\webencodings-0.5.1-py2.py3-none-any.whl
Processing \\workpc\py-packages\six-1.16.0-py2.py3-none-any.whl
Processing \\workpc\py-packages\pywin32_ctypes-0.2.0-py2.py3-none-any.whl
Processing \\workpc\py-packages\pyparsing-2.4.7-py2.py3-none-any.whl
Processing \\workpc\py-packages\ptyprocess-0.7.0-py2.py3-none-any.whl
Processing \\workpc\py-packages\urllib3-1.26.7-py2.py3-none-any.whl
Processing \\workpc\py-packages\idna-3.2-py3-none-any.whl
Processing \\workpc\py-packages\charset_normalizer-2.0.6-py3-none-any.whl
Processing \\workpc\py-packages\certifi-2021.5.30-py2.py3-none-any.whl
Processing \\workpc\py-packages\filelock-3.3.0-py3-none-any.whl
Processing \\workpc\py-packages\backports.entry_points_selectable-1.1.0-py2.py3-none-any.whl
Processing \\workpc\py-packages\platformdirs-2.4.0-py3-none-any.whl
Processing \\workpc\py-packages\distlib-0.3.3-py2.py3-none-any.whl
Installing collected packages: urllib3, idna, charset-normalizer, certifi, requests, pylev, pastel, msgpack, crashtest, webencodings, six, pywin32-ctypes, pyparsing, ptyprocess, platformdirs, lockfile, filelock, distlib, clikit, cachecontrol, backports.entry-points-selectable, virtualenv, tomlkit, shellingham, requests-toolbelt, poetry-core, pkginfo, pexpect, packaging, keyring, html5lib, cleo, cachy, poetry
Successfully installed backports.entry-points-selectable-1.1.0 cachecontrol-0.12.6 cachy-0.3.0 certifi-2021.5.30 charset-normalizer-2.0.6 cleo-0.8.1 clikit-0.6.2 crashtest-0.3.1 distlib-0.3.3 filelock-3.3.0 html5lib-1.1 idna-3.2 keyring-21.8.0 lockfile-0.12.2 msgpack-1.0.2 packaging-20.9 pastel-0.2.1 pexpect-4.8.0 pkginfo-1.7.1 platformdirs-2.4.0 poetry-1.1.11 poetry-core-1.0.7 ptyprocess-0.7.0 pylev-1.4.0 pyparsing-2.4.7 pywin32-ctypes-0.2.0 requests-2.26.0 requests-toolbelt-0.9.1 shellingham-1.4.0 six-1.16.0 tomlkit-0.7.2 urllib3-1.26.7 virtualenv-20.8.1 webencodings-0.5.1

無事完了。ちゃんとインストールできてるのも確認できる。

username@workpc: Documents > pip list
Package                           Version
--------------------------------- ---------
backports.entry-points-selectable 1.1.0
CacheControl                      0.12.6
cachy                             0.3.0
certifi                           2021.5.30
charset-normalizer                2.0.6
cleo                              0.8.1
clikit                            0.6.2
crashtest                         0.3.1
distlib                           0.3.3
filelock                          3.3.0
html5lib                          1.1
idna                              3.2
keyring                           21.8.0
lockfile                          0.12.2
msgpack                           1.0.2
packaging                         20.9
pastel                            0.2.1
pexpect                           4.8.0
pip                               21.2.3
pkginfo                           1.7.1
platformdirs                      2.4.0
poetry                            1.1.11
poetry-core                       1.0.7
ptyprocess                        0.7.0
pylev                             1.4.0
pyparsing                         2.4.7
pywin32-ctypes                    0.2.0
requests                          2.26.0
requests-toolbelt                 0.9.1
setuptools                        57.4.0
shellingham                       1.4.0
six                               1.16.0
tomlkit                           0.7.2
urllib3                           1.26.7
virtualenv                        20.8.1
webencodings                      0.5.1

というわけで、PyPI.org に繋がらない環境でも pip がつかえるのが確認できた。まぁわかってたけどね。

poetryはつかえない

つぎはインストールしたばかりの poetry を試してみた。

username@workpc: Documents > poetry config virtualenvs.in-project true
username@workpc: Documents > poetry new py-sample
Created package py_sample in py-sample

できたプロジェクトフォルダに入って、click を追加してみた。すると:

username@workpc: Documents > cd py-sample
username@workpc: py-sample > poetry add click
Creating virtualenv py-sample in C:\Users\username\Documents\py-sample\.venv

  ConnectionError

  HTTPSConnectionPool(host='pypi.org', port=443): Max retries exceeded with url: /pypi/click/json (Caused by NewConnectionError('<urllib3.connection.HTTPSConnection object at 0x0000020637A223D0>: Failed to establish a new connection: [WinError 10060] 接続済みの呼び出し 先が一定の時間を過ぎても正しく応答しなかったため、接続できませんでした。または接続済みのホストが応答しなかったため、確立された接続は失敗しました。'))

  at ~\AppData\Local\Programs\Python\Python39\lib\site-packages\requests\adapters.py:516 in send
      512│             if isinstance(e.reason, _SSLError):
      513│                 # This branch is for urllib3 v1.22 and later.
      514│                 raise SSLError(e, request=request)
      515│
    → 516│             raise ConnectionError(e, request=request)
      517│
      518│         except ClosedPoolError as e:
      519│             raise ConnectionError(e, request=request)
      520│

こんなエラーに。そうすんなりとは行ってくれないか。

ググってみると、Poetry の日本語ドキュメントのページを見つけた。これによると pyproject.toml ファイルを編集してプライベートなリポジトリを追加できるようなので、こんなふうに追記した。

[[tool.poetry.source]]
name = "private"
url = "file://workpc/py-packages/"

さっきの共有フォルダを見に行ってくれることを期待して書いたんだけど、結果は次の通り:

username@workpc: py-sample > poetry add click

  InvalidSchema

  No connection adapters were found for 'file://workpc/py-packages/click/'

  at ~\AppData\Local\Programs\Python\Python39\lib\site-packages\requests\sessions.py:742 in get_adapter
      738│             if url.lower().startswith(prefix.lower()):
      739│                 return adapter
      740│
      741│         # Nothing matches :-/
    → 742│         raise InvalidSchema("No connection adapters were found for {!r}".format(url))
      743│
      744│     def close(self):
      745│         """Closes all adapters and as such the session"""
      746│         for v in self.adapters.values():

違うエラーを吐いた。どうやら PyPI.org の代わりに指定したプライベートなリポジトリを探しに行ってはくれてるようだけど、file: スキーマをサポートしてないようだ。ドキュメントを見る限りでは、プライベートなリポジトリとはいっても PyPI.org 相当の機能が必要で、パッケージファイルが置いてあればいいというわけではないらしい。ということは pipiserver を立てるしかないのか。

残念な結果に終わった。

Python for Windows: poetryの挙動がおかしい

最近 Python をバージョンアップしたら、poetry の挙動がおかしい。poetry addpoetry install を実行して仮想環境にパッケージをインストールしようとしてもエラーを吐く。正確に言うとバージョンアップのせいなのかどうかはよくわからないのだけど、ほかに思い当たることもないんだ。

順を追ってみてみよう。Python と poetry のバージョンから。どちらも最新版。

takatoh@montana: w > python -V
Python 3.9.7
takatoh@montana: w > poetry -V
Poetry version 1.1.10

さて、まずは poetry new で新しいプロジェクトを作る。

takatoh@montana: w > poetry new py-sample
Created package py_sample in py-sample

で、できたプロジェクトのフォルダに入って poetry add する。今回はよく使う click を追加してみた。

takatoh@montana: w > cd py-sample
takatoh@montana: py-sample > poetry add click
Creating virtualenv py-sample in C:\Users\takatoh\Documents\w\py-sample\.venv
Using version ^8.0.1 for click

Updating dependencies
Resolving dependencies...

Writing lock file

Package operations: 11 installs, 0 updates, 0 removals

  • Installing pyparsing (2.4.7)
  • Installing atomicwrites (1.4.0)
  • Installing attrs (21.2.0)
  • Installing colorama (0.4.4)
  • Installing more-itertools (8.10.0)
  • Installing packaging (21.0)
  • Installing pluggy (0.13.1)
  • Installing py (1.10.0)
  • Installing wcwidth (0.2.5)
  • Installing click (8.0.1)
  • Installing pytest (5.4.3)

click のほかに仮想環境で使うパッケージもあわせて正常にインストールできた。

つぎに、いったん上のフォルダに移動して、別の新しいプロジェクトを作る。

takatoh@montana: py-sample > cd ..
takatoh@montana: w > poetry new py-sample-2
Created package py_sample_2 in py-sample-2

今できたほうのフォルダに入って、さっきと同じように poetry add で click を追加してみる。すると:

takatoh@montana: w > cd py-sample-2
takatoh@montana: py-sample-2 > poetry add click
Creating virtualenv py-sample-2 in C:\Users\takatoh\Documents\w\py-sample-2\.venv
Using version ^8.0.1 for click

Updating dependencies
Resolving dependencies...

Writing lock file

Package operations: 11 installs, 0 updates, 0 removals

  • Installing pyparsing (2.4.7)

  ValueError

  File \C:\Users\takatoh\AppData\Local\pypoetry\Cache\artifacts\92\0f\cf\effdcd5d76a6186df0969f85b3b030284ff8058936d5016540b5258ea3\pyparsing-2.4.7-py2.py3-none-any.whl does not exist

  at ~\AppData\Local\Programs\Python\Python39\lib\site-packages\poetry\core\packages\file_dependency.py:40 in __init__
       36│             except FileNotFoundError:
       37│                 raise ValueError("Directory {} does not exist".format(self._path))
       38│
       39│         if not self._full_path.exists():
    →  40│             raise ValueError("File {} does not exist".format(self._path))
       41│
       42│         if self._full_path.is_dir():
       43│             raise ValueError("{} is a directory, expected a file".format(self._path))
       44│


Failed to add packages, reverting the pyproject.toml file to its original content.

こんなふうにエラーになる。追加しようとした click じゃなくて仮想環境に必要な pyparsing っていうパッケージをインストールしようとしたところでエラーになってるのがわかる。poetry は一度インストールに使ったファイルはキャッシュしてて、このエラーはキャッシュしてるはずのファイル(\C:\Users\takatoh\AppData\Local\pypoetry\Cache\artifacts\92\0f\cf\effdcd5d76a6186df0969f85b3b030284ff8058936d5016540b5258ea3\pyparsing-2.4.7-py2.py3-none-any.whl)が見つからない、と言っている(ValueError の後に続く部分)。

ところが、ファイルはちゃんと存在する。

takatoh@montana: w > tree C:\Users\takatoh\AppData\Local\pypoetry\Cache\artifacts /F
フォルダー パスの一覧
ボリューム シリアル番号は 00000062 681C:8AA1 です
C:\USERS\TAKATOH\APPDATA\LOCAL\PYPOETRY\CACHE\ARTIFACTS
├─29
│  └─58
│      └─fc
│          └─ed8b7451d3ef91a6465024f5656141da996e7aafd4d41a1659629a75e7
│                  pluggy-0.13.1-py2.py3-none-any.whl
│
├─3d
│  └─af
│      └─11
│          └─94d692f8e0bf0791d76d42d8d257be6c7d2cba22ca4f745856c83286e9
│                  more_itertools-8.10.0-py3-none-any.whl
│
├─60
│  └─79
│      └─0b
│          └─c48bd9c2a989aa8b1eb7a67cd02b053c10734f2e4e5665f7995f09999c
│                  py-1.10.0-py2.py3-none-any.whl
│
├─6f
│  └─a9
│      └─ee
│          └─569c37f69a8c365ee41d2340aeac0214ee8c0086b8d8db43a21545204b
│                  attrs-21.2.0-py2.py3-none-any.whl
│
├─7d
│  └─f4
│      └─60
│          └─0737157bb9711fec72c70dff523aa54491eef317e0d586cf5388ff0908
│                  wcwidth-0.2.5-py2.py3-none-any.whl
│
├─92
│  └─0f
│      └─cf
│          └─effdcd5d76a6186df0969f85b3b030284ff8058936d5016540b5258ea3
│                  pyparsing-2.4.7-py2.py3-none-any.whl
│
├─9e
│  └─b3
│      └─11
│          └─7d87ac44fdb2d557301f1f4086a37c080d1482a98751abe7cdbabbad26
│                  colorama-0.4.4-py2.py3-none-any.whl
│
├─ae
│  └─32
│      └─83
│          └─e159324c1bd58177322f4e45f598d500fe22544bff20f53f55cf749da8
│                  click-8.0.1-py3-none-any.whl
│
├─b7
│  └─99
│      └─9c
│          └─c8ddc18c8225c740fc2ae6d503da3f93d5dddaf04afac3da460afacbc6
│                  atomicwrites-1.4.0-py2.py3-none-any.whl
│
├─e4
│  └─88
│      └─dd
│          └─3dbff42e3c4462dc3d027f5024025571166cb35edee215ce93852a968c
│                  pytest-5.4.3-py3-none-any.whl
│
└─f9
    └─4f
        └─09
            └─c91a145b26102e014fd6e33bd8c7b87306c8e1d4a771158f34dd13210e
                    packaging-21.0-py3-none-any.whl

pyparsing はなかほどに出力されてるね。なのに見つからないってどういうことさ。

さて、poetry の挙動がおかしいのはこれだけじゃない。というかこれから本番というか。

存在するのに見つからないっていうキャッシュをフォルダごと削除してやると、今度はインストールがうまくいく。まず削除。

takatoh@montana: w > rm C:\Users\takatoh\AppData\Local\pypoetry\Cache\artifacts -Recurse -Force

これで削除できてる。

takatoh@montana: w > tree C:\Users\takatoh\AppData\Local\pypoetry\Cache\artifacts /F
フォルダー パスの一覧
ボリューム シリアル番号は 00000066 681C:8AA1 です
C:\USERS\TAKATOH\APPDATA\LOCAL\PYPOETRY\CACHE\ARTIFACTS
無効なパスです - \USERS\TAKATOH\APPDATA\LOCAL\PYPOETRY\CACHE\ARTIFACTS
サブフォルダーは存在しません

で、あらためて poetry add click

takatoh@montana: w > cd py-sample-2
takatoh@montana: py-sample-2 > poetry add click
Using version ^8.0.1 for click

Updating dependencies
Resolving dependencies...

Package operations: 11 installs, 0 updates, 0 removals

  • Installing pyparsing (2.4.7)
  • Installing atomicwrites (1.4.0)
  • Installing attrs (21.2.0)
  • Installing colorama (0.4.4)
  • Installing more-itertools (8.10.0)
  • Installing packaging (21.0)
  • Installing pluggy (0.13.1)
  • Installing py (1.10.0)
  • Installing wcwidth (0.2.5)
  • Installing click (8.0.1)
  • Installing pytest (5.4.3)

ほら、ちゃんとインストールできた。どういうことだよ!

Python: pipでローカルネットワー上のプライベートなリポジトリからインストールする

仕事で使うちょっとしたツール(大抵はテキスト処理のツール)を Python で書いたりしてるんだけど、一つ不便なことがある。職場のネットワークから PyPI にアクセスできないんだ。

まぁ、セキュリティだの何だの考えるとインターネットへのアクセスを制限するのもわからないではない。

幸いブラウザでは繋がるので、ファイルを一つずつダウンロードしてからインストールすることは可能で、今までそうやってたんだけど不便なことこの上ない。パッケージがファイル一つで済むならまだいいんだけど、他のパッケージに依存してたりすると、正直やる気が失せてくるし、同僚に使ってもらおうというときにもハードルが高い。

さて、今日になって pip install コマンドの --find-links オプションでパッケージ置き場の URL を指定できることを知った。pypiserver みたいな PyPI クローンのサーバじゃなくて、ディレクトリにパッケージファイルを置いておくだけでいい。

早速、自宅の HTTP サーバにディレクトリを公開して試してみた。こんな感じ。

takatoh@apostrophe:~$ pip install filtre --find-links http://nightschool/py-packages/ --trusted-host nightschool
Looking in links: http://nightschool/py-packages/
Collecting filtre
  Downloading http://nightschool/py-packages/filtre-0.1.0-py3-none-any.whl (1.9 kB)
Installing collected packages: filtre
Successfully installed filtre-0.1.0

http://nightschool/py-packages/ がパッケージファイルを置いてあるディレクトリの URL。--trusted-host nightschool オプションを指定してるのは HTTPS じゃなくて HTTP だから。filtre ってのは自作のパッケージね。

さらに、Windows なら file: スキーマでも行ける。上のディレクトリは Samba で共有フォルダにしてあって file://nightschool/shared/py-packages/ でアクセスできるので、こうすればいい。

takatoh@montana: Documents > pip install filtre --find-links file://nightschool/shared/py-packages/ --user
Looking in links: file://nightschool/shared/py-packages/
Processing \\nightschool\shared\py-packages\filtre-0.1.0-py3-none-any.whl
Installing collected packages: filtre
Successfully installed filtre-0.1.0

やぁ、これでだいぶ楽になるぞ。

ちなみに、パッケージファイルをダウンロード(インストールじゃなくて)するには pip download コマンドを使えば依存パッケージも含めてダウンロードしてくれる。使いそうなパッケージを自宅でダウンロードしたら、職場に持っていってまとめて適当な共有フォルダに放り込んでおけばいい。

[追記]

pip install コマンドの --find-links オプションや --trusted-host オプションは、pip の設定ファイルに書いておけばいちいち指定しなくてもいい。こんな感じ。

[install]
find-links = http://nightschool/py-packages/
trusted-host = nightschool

pip の設定ファイルは、Linux なら ~/.conf/pip/pip.conf、Windows なら %APPDATA%\pip\pip.ini。

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 が返ってくるため。

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