pythonで特定のpackageに含まれるresource(物理ファイル)にアクセスする方法

例えば、lib2to3 packageは Grammar.txt というテキストファイルを持っている。

$ pwd
/usr/lib/python3.7/lib2to3
$ tree -I "*.py*" -L 1
.
├── Grammar.txt
├── Grammar3.7.3.final.0.pickle
├── PatternGrammar.txt
├── PatternGrammar3.7.3.final.0.pickle
├── __pycache__
├── fixes
├── pgen2
└── tests

$ head Grammar.txt -n 5
# Grammar for 2to3. This grammar supports Python 2.x and 3.x.

# NOTE WELL: You should also follow all the steps listed at
# https://devguide.python.org/grammar/

このようなある特定のpackage(lib2to3)が保持する物理的なファイル(Grammar.txt)をresourceと呼ぶらしい(ただし正確に言うとこれは喩えであって必ずしも物理的なファイルである必要はない)。

これへのアクセスの仕方をどうするのかというのが今回の主題。

自前でやっても良いのだけれど importlib.resources が便利

自前でやっても良いのだけれどいろいろ面倒。実際の所python3.7なら importlib.resourcesが存在する。こちらを使うのが良いかもしれない。

以下のような形でアクセスできる。

from importlib.resources import read_text

print(read_text("lib2to3", "Grammar.txt"))

手軽ですね。

importlib.resources が使えるのは3.7から

ただしimportlib.resources が使えるのは3.7から。なので3.7未満のバージョンのことは別途考えなくちゃいけない。

ところでこのimportlib.resourcesはドキュメントを読むと以下のような注意書きが書いてある。

This module provides functionality similar to pkg_resources Basic Resource Access without the performance overhead of that package. This makes reading resources included in packages easier, with more stable and consistent semantics.

The standalone backport of this module provides more information on using importlib.resources and migrating from pkg_resources to importlib.resources.

元々は pkg_resources というpackageの中でこれらのresourceにアクセスする機能が提供されていて、それが広く使われていたのだけれど、このpackageは読み込んだだけで無駄なオーバーヘッドが存在したりなどしていた。

pkg_resources

過去には以下の様なコードでresourceを取り出していたのだけれど。これは非推奨(老兵はお疲れさまというねぎらいの言葉をかけられつつ去るみたいな話(?))。

import pkg_resources

print(pkg_resources.resource_string("lib2to3", "Grammar.txt").decode("utf-8"))

このブログでもpkg_resourcesに触れたことがあった。例えば以下のような記事がとか。

<3.7ではどうすれば良いの?

3.7未満ではどうすれば良いのかということを考えてみる。pythonのpackageではバージョンの差異を吸収するためにcompat.pyというファイルを用意することが多い。compat.pyを作るのは例えばpython2.xとpython3.xの違いを吸収するときなどにも使われたけっこう永い慣習。

次にこのcompat.pyをどうやって作るかだけれど。最初は「どうせ時間が経てば、すべてのpythoのバージョンは3.7以上になるだろうし。あんまりまじめに頑張って対応しなくても良いだろう。どちらかと言うとコードの複雑さのようなものを持ち込みたくない。せいぜいオーバーヘッドがあるくらいなら良いことにしちゃおう」と思ったりした。

なので以下のようなcompat.pyを作ろうとした。

try:
    from importlib.resources import read_text as resource_text
except ImportError:
    import pkg_resources

    def resource_text(package, resource, encoding="utf-8"):
        return pkg_resources.resource_string(package, resource).decode(encoding)

関数名の部分で悩ましいところがあったりはする。compat.pyだから他から使う場合には compat.<function name><function name> という形で利用されることになる。ところで compat.read_text では何がなんだかわからない。ということで名前を resource_text() に変えている。

そして他の所では以下の様なコードを書くことになる。

from compat import resource_text

print(resource_text("lib2to3", "Grammar.txt"))

ちょっとした関数名の別解

よりstrictにcompat.pyを考えて、あるpackageにおけるバージョン間の差異を吸収するモジュールと考えるのではなく、後のpythonのバージョンで提供されるであろう機能のpolyfilと捉える人も居るかもしれない(提供する名前を尊重しようという立場)。

そのように考えた場合の利点は他のpackageに持っていったときにも操作の名前に互換性があるということ。ただしそれのためにmonkey patchをするなどはちょっとやりすぎだと思うので穏やかに解決するなら、やるとしてもresources という名前空間を用意するくらいでとどめておくのが無難かもしれない。

その場合は以下の様な形になる。

compat2.py

try:
    from importlib import resources
except ImportError:
    import pkg_resources

    class resources:
        @staticmethod
        def read_text(package, resource, encoding="utf-8"):
            return pkg_resources.resource_string(package, resource).decode(encoding)

利用方法は以下の様なかたちになる。

from compat2 import resources

print(resources.read_text("lib2to3", "Grammar.txt"))

まぁ compat.read_text() よりはマシかもしれない。

(実は↑の実装は手抜きで完全なmodule objectが欲しい場合に上手く動かないところもあるかもしれない。一般的な操作に対するものならけっこうこれくらいで大丈夫。)

import_resources

どうせ1行程度だしこれでも良いかなと思ったのだけれど。pytho3.7のWhat's Newを覗いてみたら以下の様なことが書いてあった。

The new importlib.resources module provides several new APIs and one new ABC for access to, opening, and reading resources inside packages. Resources are roughly similar to files inside packages, but they needn't be actual files on the physical file system. Module loaders can provide a get_resource_reader() function which returns a importlib.abc.ResourceReader instance to support this new API. Built-in file path loaders and zip file loaders both support this.

Contributed by Barry Warsaw and Brett Cannon in bpo-32248.

そしてバックポートpackageがへのリンクが貼られていた(実は先程の注意書きの部分でもbackportに触れている文章がありますね)。

importlib_resources -- a PyPI backport for earlier Python versions

pypi.org

なるほど。というわけでpython3.7未満の人向けにはこちらを使うのが丁寧かもしれない。

$ pip search importlib_resources
importlib_resources (1.0.2)  - Read resources from Python packages

例えばsetup.pyなどでは以下の様な形で install_requires を書くことになる。

setup.py (の一部)

from setuptools import setup, find_packages


install_requires = []
if sys.version_info[:2] < (3, 7):
    install_requires.append("importlib_resources")


setup(
...
      install_requires=install_requires,
...
)

そしてcompat.pyは以下の様な感じ。長さが気にならなければフルの修飾付き importlib_resources の方が混乱はないかもしれない。

compat3.py

try:
    from importlib import resources as importlib_resources
except ImportError:
    import importlib_resources

使うときはもちろん以下の様な形。

from compat3 import importlib_resources

print(importlib_resources.read_text("lib2to3", "Grammar.txt"))

もう少し内部での実装

最初、importlib.resources が3.7でしか使えないと分かった時にもう少し内部での実装でcompat.pyを手軽に定義できないかなと考えたりもした。ちなみに以下の方法も3.7でしか使えなかったのでこういう書き方もあるよというだけの紹介。

from importlib.util import find_spec

spec = find_spec("lib2to3")
print(spec.loader.open_resource("Grammar.txt").read())

ファイルパスなどを知りたければ以下のようにしてしらべられる。

spec.loader.path  # => '/usr/lib/python3.7/lib2to3/__init__.py'
spec.loader.resource_path("Grammar.txt")  # => '/usr/lib/python3.7/lib2to3/Grammar.txt'

packageをディレクトリと見立てると __init__.py の扱いがめんどくさかったりするのでpackageとresourceという形でアクセスできるのはやっぱり便利。

(そのおかげで importlib.resources.path() などはけっこう頑張った実装になってはいるのだけれど。つまり物理的なファイルが存在しない場合の処理も入っているということ)。

ちなみにその他にもimportlib.utilは地味に便利な関数群が存在していたりする。個人的にはあるpackageから見た相対パス(相対インポートで使う表現)を解釈してフルパスにしてくれる importlib.util.resolve_name() などが好きだったりする

汚いvenv環境からきれいなrequirements.lockを作りたい

手元の環境がものすごく汚いことがある。例えば以下の様に複数のprojectの依存が混ざってしまった状態。

requirements.txt

# app1 の依存
flask
peewee
wtforms

# app2 の依存
pyramid
mako
sqlalchemy

# (後で使う)
pipdeptree

これをきれいに2つに分けて出力したい。通常は pip freeze でrequirements.lockを作るが手元の環境に入っているパッケージの全てが出力されてしまう。

そうではなく例えば↑の依存を

  • flask-requirements.lock (app1用)
  • pyramid-requirements.lock (app2用)

という形に分けたい。また上のrequierments.txtに載っているpackageだけのバージョンを固定してもそれら(e.g. flaskやpyramid)の再帰的な依存までは固定されないので嬉しくない。

ただしvenvを作り直して再インストールは面倒。

こういう気持ち。

(go mod tidy的なものが欲しい)

pipdeptree

幸いなことにpipdeptreeを使うと上手く行く。

pypi.org

# pip install pipdeptree
$ pipdeptree -h
  -f, --freeze          Print names so as to write freeze files

...
  -p PACKAGES, --packages PACKAGES
                        Comma separated list of select packages to show in the output. If
                        set, --all will be ignored.
  -e PACKAGES, --exclude PACKAGES
                        Comma separated list of select packages to exclude from the output.
                        If set, --all will be ignored.

-p で特定のパッケージのみに対する依存を調べてくれる。-fpip freeze で出力するのと同様の表現で出力できる。便利。

具体例

-p <Packages> の部分にトップレベルの依存を指定する

( ただし -f はインデントされた表現になったり、重複して出力されるのでsedやsortで整形する)

# app1
# flask,jinja2,peewee だけからなる依存を集める
$ pipdeptree -p flask,mako,sqlalchemy -f | sed 's/^ *//g' | sort -u | tee flask-requirements.lock

# app2
# pyramid,mako,sqlalchemy だけからなる依存を集める
$ pipdeptree -p pyramid,mako,sqlalchemy -f | sed 's/^ *//g' | sort -u | tee pyramid-requirements.lock

入力のファイルを分ける

個別にコマンドラインで指定するのが嫌なら以下の様にファイルに依存を書いて上げても良い。

pyramid.txt

pyramid
mako
sqlalchemy

trで変換する

$ pipdeptree -f -p "$(cat ./pyramid.txt | tr '\n' ',')" | tee pyramid-requirements.txt

不完全なfreezeが入力の場合

不完全なfreezeが入力の場合は editable installが存在する場合もある。こういうもの。

$ pip freeze | grep '^\-e'
-e git+git@github.com:podhmo/dictknife@daadc0824407bf7877d9bc755e07d62b0ee0b820#egg=dictknife
-e git+git@github.com:podhmo/handofcats@8bb2ee3d96d110ff73e16e15bf2fb56e4e7d7af3#egg=handofcats
-e git+git@github.com:podhmo/jqfpy@e33bdd91d8d724c1cafeccedee3b9e7f06b21d1e#egg=jqfpy

grep -v でよしなに取り除いたりしてください :bow:

gist

https://gist.github.com/podhmo/c6ec298da028950347013668ec0e46d2