複数のファイルを利用したpythonスクリプトを1つの実行可能なzipファイルにまとめる方法のメモ

pythonのscriptを1つのファイルにまとめたい。そのような場合にはzipappの機能が使えるかもしれない。

zipファイルとして1つにまとめたファイルはpythonインタプリタで直接実行できる。

このモジュールは Python コードを含む zip ファイルの作成を行うツールを提供します。 zip ファイルは Python インタープリタで直接実行することが出来ます。

ただし、後述するが、外部パッケージを利用するものに関してはzipappではない方が良いかもしれない。

外部パッケージを利用しない場合

外部パッケージを利用しない場合、つまり標準ライブラリだけのスクリプトの場合にはzipappだけで済む。

例えば以下の様なファイル構成でのmain.pyを1つのファイルとして実行可能にしたい。

../myzipapp
├── Makefile
├── foo
│   ├── __init__.py
│   └── hello.py
└── main.py

1 directory, 4 files

main.pyはportの8000番でテキトーなwsgi appを動かすようなコード。

python main.py
Serving on port 8000...
127.0.0.1 - - [03/Feb/2021 22:54:18] "GET / HTTP/1.1" 200 26

hello worldというrequestが返ってくる。

$ http :8000
HTTP/1.0 200 OK
Content-Length: 26
Content-type: application/json; charset=utf-8
Date: Wed, 03 Feb 2021 13:54:18 GMT
Server: WSGIServer/0.2 CPython/3.8.5

{
    "message": "hello world"
}

このような挙動のmain.pyからapp.pyzを作る。作られたapp.pyzは直接実行可能になる。

$ python -m zipapp -c -o app.pyz ../myzipapp -m "main:main"

# 異なる場所に置く
$ mv app.pyz /tmp
$ python /tmp/app.pyz
Serving on port 8000...
127.0.0.1 - - [03/Feb/2021 23:16:05] "GET / HTTP/1.1" 200 26

ちなみに、このzipファイルは以下の様な内容になっている。

$ zipinfo /tmp/app.pyz
Archive:  /tmp/app.pyz
Zip file size: 1354 bytes, number of entries: 7
-rw-r--r--  2.0 unx        0 b- defN 21-Feb-03 23:14 app.pyz
drwxr-xr-x  2.0 unx        0 b- stor 21-Feb-03 23:11 foo/
-rw-r--r--  2.0 unx      188 b- defN 21-Feb-03 22:58 main.py
-rw-r--r--  2.0 unx      192 b- defN 21-Feb-03 23:12 Makefile
-rw-r--r--  2.0 unx      533 b- defN 21-Feb-03 22:50 foo/__init__.py
-rw-r--r--  2.0 unx       56 b- defN 21-Feb-03 22:46 foo/hello.py
?rw-------  2.0 unx       48 b- defN 21-Feb-03 23:14 __main__.py
7 files, 1017 bytes uncompressed, 672 bytes compressed:  33.9%

つまるところ、指定したディレクトリを1つのモジュールとして扱い、__main__.pyが追加されるという形。生成された __main__.py-m で指定されたエントリーポイントを呼び出すだけのファイル。

__main__.py

# -*- coding: utf-8 -*-
import mian
main.main()

ちなみに他のファイルは以下の様な内容だった。このコード自体はあまり重要ではない。ただのJSONを返すhttpサーバー。

main.py

import foo
from foo.hello import hello


def main():
    app = foo.make_app(lambda environ: {"message": hello("world")})
    foo.run_app(app, 8000)


if __name__ == "__main__":
    main()

foo/__init__.py

import sys
import json
from wsgiref.simple_server import make_server


def make_app(handler):
    def app(environ, start_response):
        status = "200 OK"
        headers = [("Content-type", "application/json; charset=utf-8")]
        start_response(status, headers)
        return [json.dumps(handler(environ)).encode("utf-8")]

    return app


def run_app(app, port):
    httpd = make_server("", port, app)
    print(f"Serving on port {port}...", file=sys.stderr)

    # Serve until process is killed
    httpd.serve_forever()

foo/hello.py

def hello(name: str) -> str:
    return f"hello {name}"

外部パッケージを利用する場合

さて、先程のzipappは外部パッケージを利用しない場合のもの。zipapp自体は外部パッケージの依存をいい感じに管理はしてくれない。依存したものをzipファイル化したい場合には、いろいろ自分で調整する必要がある

そんな面倒なことをしたくないという場合にはshivが使える。 github.com

ただしsetup.pyを書いてあげる必要がある。パッケージの依存を見たいので。

$ tree .
├── Makefile
├── main.py
└── setup.py

0 directories, 3 files

今度はテキトーなライブラリを使ったコードにする。とりあえずfastapiでも使うことにする。

$ python main.py
INFO:     Started server process [74075]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     127.0.0.1:64986 - "GET / HTTP/1.1" 200 OK

以下のようにshivを実行する。console_scriptsを指定して実行されるスクリプトを決めてあげる。

# shiv -o <file> -c <console script>
$ shiv -o myapp.pyz -c myapp2 .

myapp2になっている理由は、setup.pyのconsole_scriptsの設定がそうだったため。

setup.py

from distutils.core import setup

setup(
    name="myapp2",
    version="0.0.0",
    data_files=["main.py"],  # 真面目にモジュールを分けた場合にはpackages=find_packages()を使うかもしれない
    install_requires=["fastapi", "uvicorn"],
    entry_points="""
[console_scripts]
myapp2 = main:main
""",
)

main.py

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
def hello():
    return {"message": "Hello World"}


def main():
    import os
    import uvicorn

    port = int(os.environ.get("PORT") or "8000")
    uvicorn.run(app, port=port)


if __name__ == "__main__":
    main()

もちろん別の場所で普通に動く。

$ mv myapp.pyz /tmp

# 空の仮想環境で実行
$ python -m venv xxx
$ . xxx/bin/activate
(xxx) $ pip freeze

(xxx) $ python /tmp/myapp.pyz
INFO:     Started server process [74409]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

zipファイルに含まれた内容はこんな感じ(長いのでgistへのリンク)

see also

ちなみに提供の形態を把握するにはこのページが便利。

pythonインタプリタ自体も同梱したい場合にはPyOxidizerあたりが良いかもしれない。

github.com

gist