c-bata web

@c_bata_ のメモ。python と Go が多めです。

Double forkによるプロセスのデーモン化と、ファイル変更時の自動サーバーリロードの実装 (Python)

Pythonで約100行のシンプルなWSGIサーバーを書いてみる - c-bata webWSGIサーバーを作ってみました。 100行程度の非常に簡易的なものでしたが、実際にDjangoアプリケーションを動かすこともできました。 前回作ったWSGIサーバーをもう少し便利に使えるようにいくつか機能を追加したのですが、 その中でもWSGIサーバーに限らず知っておくとよさそうな3つの実装を紹介します。

目次:

Double Fork によるサーバープロセスのデーモン化

WSGIサーバーのように長時間動かすようなプログラムはデーモン化しておきたい場合があります。gunicornでも daemon オプションが用意されていて設定で簡単に切り替えることができます。 ところでバックグラウンドで実行するだけなら端末上でコマンドを入力した後に & をつけることもできます。 デーモン化とはなにか違うのでしょうか。少し試してみましょう。

$ python3.7 -m http.server 8000 &
[1] 37682
a14737: ~
$ Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

この例では & をつけたことでバックグラウンドになり、別のコマンドを実行できる状態になりましたが、標準ストリームはまだ繋がっていてプロセスの出力が表示されてしまっています。例えば localhost:8000 にアクセスしてみると 127.0.0.1 - - [29/Sep/2018 22:51:32] "GET / HTTP/1.1" 200 - のログがこの端末上に流れます。これ以外にもいくつか問題が残っていて、プログラムによってはSSH接続を切ったときに送られる SIGHUP を受け取ると終了してしまいます。デーモン化というのはこのようにただバックグラウンドにするわけではなく、プロセスから制御端末(tty)を完全に切り離します。

制御端末からプロセスを切り離すために有効なのは setsid() システムコールです*1。これについて知るためにはプロセスグループやセッションという概念を理解しておく必要があります。ただぐぐると詳しい記事が出てくるので詳しい説明はそちらにおまかせします *2

サーバーにログインすると、ログインセッションが作られます。このセッションのリーダーであるプロセス1つが制御端末(tty)とやり取りを行います。システムコール setsid() を呼び出すと新しいセッションを作成しそのセッショングループのリーダーになります。ここで作成されたセッションはまだ制御端末(tty)を取得していないのでプロセス上に制御端末がなくなったことになります。ただこのとき2つ注意点があります。

  1. もし setsid() システムコールを呼び出したプロセスが、プロセスグループリーダーだった場合はエラーが返ってきます。
  2. setsid() を呼ぶことで新しいセッションのセッショングループリーダーになりますが、セッショングループリーダーは唯一制御端末とやりとりすることが可能です。プログラムの実装によってはあとで制御端末を取得してしまうかもしれません。

下図に示す「double fork」と呼ばれるテクニックを利用することでこの2つの懸念が解消されます。

f:id:nwpct1:20181001191511p:plain

double fork の名の通り、2回 fork(2) を呼び出します。

  1. 最初に子プロセスを生成した後、親プロセス側で sys.exit(0) を呼びすぐに終了する。子プロセス側は必ずグループリーダーではないため、 setsid() システムコールが呼び出せます。
  2. 子プロセス側で sys.setsid() を呼び出し、セッショングループリーダーになり制御端末から切り離します。しかしこの子プロセスはセッショングループリーダーです。さらに孫プロセスを生成し、セッショングループリーダーである子プロセスを終了してしまえば、このセッションに制御端末が取得されることはありません。

ソースコードは次のとおりです。

# https://github.com/kobinpy/kwsgi/pull/2

def daemonize():
    """Detaches the server from the controlling terminal and enters the background."""
    if os.fork():
        # Exit a parent process because setsid() will be fail
        # if you're a process group leader.
        sys.exit(0)

    # Detaches the process from the controlling terminal.
    os.setsid()

    if os.fork():
        sys.exit(0)

    # Continue to run application if directory is removed.
    os.chdir('/')

    # 0o22 means 755 (777-755)
    os.umask(0o22)

    # Remap all of stdin, stdout and stderr on to /dev/null.
    # Please caution that this way couldn't support following execution:
    #   $ kwsgi ... > output.log 2>&1
    os.closerange(0, 3)
    fd_null = os.open(DEVNULL, os.O_RDWR)
    if fd_null != 0:
        os.dup2(fd_null, 0)
    os.dup2(fd_null, 1)
    os.dup2(fd_null, 2)


Pythonファイルの更新に検知してサーバーを自動で再起動する

開発用途のサーバーとしては、ファイルの更新を検知して自動でサーバーが再起動してくれると便利そうです。 これは実装が少しだけ複雑ですが、面白い実装になっています。 ソースコードも少し長くなるので細かい解説までは今回は省くことにしましたが、ソースコードにコメントを多めに入れたので読んでみてください。 このプログラムのポイントは次のあたりです。

  1. Pythonが読み込んだモジュールのファイル一覧と最終更新時刻を取得する
    1. sys.modules から読み込んでいるモジュールの一覧を取得
    2. getattr(module, '__file__') を読み出し、 os.path.exists でファイルの存在をチェック
    3. os.stat(path).st_mtime より最終更新時刻を取得する。
  2. 呼び出しコマンドをサブプロセスでもう一回実行してWSGIサーバーを実行するワーカープロセスを生成する
    1. args = [sys.executable] + sys.argvsubprocessモジュールで実行する
    2. 再帰的にコマンドが実行され続けて大変なことになるのを避けるため、環境変数 KWSGI_CHILD で制御する。
    3. Exit ステータスコードを正常終了(0)と異常終了(1)、リロード(3) で使い分けて、3なら再度ワーカープロセスを生成する。
  3. メインプロセスとワーカープロセスのやりとりは tmp 領域に作成したファイルを経由して行う
    1. tempfile.mkstemp(prefix='kwsgi.', suffix='.lock') を実行して temp 領域にファイルを作成
    2. ワーカープロセス呼び出し時の KWSGI_LOCKFILE 環境変数でファイルパスを伝える
    3. lockfileの状態を確認してファイルが削除されていたら異常終了し、更新されていたら reloadを意味する 3 をExit Statusに設定して終了する。
import os
import sys
import threading
import time
import _thread
import subprocess
import tempfile


# For reloading server when detected python files changes.
EXIT_STATUS_RELOAD = 3


class FileCheckerThread(threading.Thread):
    def __init__(self, lockfile, interval):
        threading.Thread.__init__(self)
        self.daemon = True
        self.lockfile, self.interval = lockfile, interval
        self.status = None # 'reload', 'error' or 'exit'


    def run(self):
        files = dict()

        # sys.modules からPythonモジュールの一覧を取得
        for module in list(sys.modules.values()):
            # __file__ が定義されていればそこからファイルパスが取得できる。
            path = getattr(module, '__file__', '')
            if path[-4:] in ('.pyo', '.pyc'):
                path = path[:-1]
            if path and os.path.exists(path):
                files[path] = os.stat(path).st_mtime

        while not self.status:
            # lockfile が削除 or 更新されていたら status を 'error' にして例外をraiseして通知する.
            if not os.path.exists(self.lockfile) or \
                    os.stat(self.lockfile).st_mtime < time.time() - self.interval - 5:
                self.status = 'error'
                _thread.interrupt_main()
            for path, last_mtime in files.items():
                if not os.path.exists(path) or os.stat(path).st_mtime > last_mtime:
                    # ファイルが更新されていたら status を 'reload' にセットして例外をraiseして通知する。
                    self.status = 'reload'
                    _thread.interrupt_main()
                    break
            time.sleep(self.interval)

    def __enter__(self):
        self.start()

    def __exit__(self, exc_type, *_):
        if not self.status:
            self.status = 'exit'  # silent exit
        self.join()
        return exc_type is not None and issubclass(exc_type, KeyboardInterrupt)


class AutoReloadServer:
    def __init__(self, func, args=None, kwargs=None):
        self.func = func
        self.func_args = args
        self.func_kwargs = kwargs

    def run_forever(self, interval):
        # メインプロセスから呼び出された子プロセスには 'KWSGI_CHILD' 環境変数が存在。
        # 無限に呼び出されてしまうため、環境変数で子プロセスであることを教える
        if not os.environ.get('KWSGI_CHILD'):
            lockfile = None
            try:
                fd, lockfile = tempfile.mkstemp(prefix='kwsgi.', suffix='.lock')
                os.close(fd)  # We only need this file to exist. We never write to it
                while os.path.exists(lockfile):
                    # ユーザーが端末で実行したプログラムをもう一度実行する。
                    args = [sys.executable] + sys.argv
                    environ = os.environ.copy()
                    # 無限に呼び出されてしまうため、環境変数で子プロセスであることを教える
                    environ['KWSGI_CHILD'] = 'true'
                    # ワーカープロセスとのやり取りに用いるファイルも環境変数で渡す。
                    environ['KWSGI_LOCKFILE'] = lockfile
                    p = subprocess.Popen(args, env=environ)
                    while p.poll() is None:  # Busy wait...
                        os.utime(lockfile, None)  # Alive! If lockfile is unlinked, it raises FileNotFoundError.
                        time.sleep(interval)
                    # 終了ステータスをチェックする。Reload(3) 以外なら終了する。
                    if p.poll() != EXIT_STATUS_RELOAD:
                        if os.path.exists(lockfile):
                            os.unlink(lockfile)
                            sys.exit(p.poll())
            except KeyboardInterrupt:
                pass
            finally:
                if os.path.exists(lockfile):
                    os.unlink(lockfile)
            return

        # ワーカープロセスの処理を記述する
        # ワーカープロセスはコマンド呼び出し時、 KWSGI_LOCKFILE を環境変数で指定する。
        # lockfileを通してメインプロセスと通信する。ファイルが削除されていたら終了。ファイルが更新されていたらリロードを意味する。
        try:
            lockfile = os.environ.get('KWSGI_LOCKFILE')
            bgcheck = FileCheckerThread(lockfile, interval)
            with bgcheck:
                self.func(*self.func_args, **self.func_kwargs)
            if bgcheck.status == 'reload':
                sys.exit(EXIT_STATUS_RELOAD)
        except KeyboardInterrupt:
            pass
        except (SystemExit, MemoryError):
            raise
        except:
            time.sleep(interval)
            sys.exit(EXIT_STATUS_RELOAD)

次のように使います。

server = AutoReloadServer(something_func, kwargs={'app': app, 'host': host, 'port': port})
server.run_forever(interval)

Bottleの実装を参考に勉強したのですが、lockfileによるプロセス間のステータスのやりとりや、環境変数を使ったワーカープロセスの判定は自分にとって珍しく面白い実装でした。kwsgiでは次のファイルで実装しています。

github.com


文字列で指定したPythonオブジェクトを動的に読み込んで実行する方法

最後は、コマンドラインアプリケーションをつくるときに知っておくと便利なTipsです。 gunicorn や uWSGI といったWSGIサーバーのコマンドラインインターフェイスでは、実行対象のファイル名とそこに書かれてあるWSGIアプリケーションをコマンドライン引数で指定します。

# hello.py
def application(env, start_response):
    start_response("200 OK", [("Content-Type", "text/plain")]
    return [b"Hello World"]

このようなファイルを用意して、 gunicorn -w 1 hello:application と指定すると、hello.py というファイルを読み込んでからその中にある application と名前のついたオブジェクトを動的に取り出し、WSGIサーバーに読み込ませる必要があります。

やりかたはいくつかありますが、Python3だけを対象にするなら importlib.machinery.SourceFileLoader を使った方法が手軽です。

from importlib.machinery import SourceFileLoader

filepath = 'target.py'
app_name = 'application'

def insert_import_path_to_sys_modules(import_path):
    abspath = os.path.abspath(import_path)
    if os.path.isdir(abspath):
        sys.path.insert(0, abspath)
    else:
        sys.path.insert(0, os.path.dirname(abspath))

insert_import_path_to_sys_modules(os.path.abspath(filepath))
module = SourceFileLoader('module', filepath).load_module()
app = getattr(t, app_name)

使い方はこのように非常に簡単です。ファイルをモジュールとして動的に読み込み getattr() で対象のオブジェクトを取得します。もしPython 2で同様のことをやるなら importlib が使えないため exec()compile() 関数を使った少しトリッキーな実装が必要になります。

import types

filepath = 'target.py'
app_name = 'application'

t = types.ModuleType('app')

with open(filepath) as config_file:
    exec(compile(config_file.read(), src.name, 'exec'), t.__dict__)

app = getattr(t, app_name)

対象のファイルを open() し、 compile() 関数により動的にファイルの中身をコンパイルしてコードオブジェクトを取得します *3。その後 exec() 関数により types.ModuleType() で用意した変数に module オブジェクトを割り当てます。 こちらの方法を使うことはもうほとんど無いかと思うので、参考までに。ちなみに以前自分が作っていたWSGIフレームワークKobinで、 exec() を使った実装から importlib を使った実装に書き換えたときのコミットは↓のrevisionです、参考までに。

github.com

謝辞

自分がdouble forkというものを知ったのは tell-k さんのPyCon JPの発表 Pythonでざっくり学ぶUnixプロセス がきっかけでした。 少し解釈に自信がない部分もあったので、本記事も tell-k さんにレビューいただきました。ありがとうございます。 double forkの2回目のforkをする理由については、別の理由とかではないか少し心配だったのですが tell-kさんも基本的には同じ解釈のようです。 2回目のforkの意図について確認した際に、tell-kさんから次の返事がきました。

私も資料作ってる時に同じ疑問に思いました。そしたら下記リンクにたどり着きましたので共有しておきます。 http://q.hatena.ne.jp/1320139299

setsid = セッションリーダーで、技術的に制御端末の割り当てが可能だから、確実に割り当てできないようにセッションリーダーではない孫を作るっていう認識は私も一緒です。

あとは daemon化とは直接関係なさそうな気はしますが、double fork することで、ゾンビプロセスを抑制できるって話は面白かったです。 http://d.hatena.ne.jp/sleepy_yoshi/20100228/p1

どうやら親が死んで init (システム上の一番最初のプロセス。pid=1, ppid=0)の養子になることが、ゾンビプロセスの発生を抑えることにも繋がっているとのことでした。なぜ init が頻繁にwaitを呼んでいるのか、その理由までは調べきれていませんが参考までに。

他にも今回はgunicornのdouble forkの実装を参考にしました。 gunicornの実装では、 chdir() を呼んでいないのですがこちらの理由もまた分かったらまた追記しておこうかとおもいます。

今日紹介した実装はどれも書いていて楽しいコードでした。 次は時間があるときにでもWSGIサーバーのパフォーマンス最適化のための方法を勉強してまとめたいなと思います。

Pythonで約100行のシンプルなWSGIサーバーを書いてみる

エキスパートPythonプログラミング改訂2版

エキスパートPythonプログラミング改訂2版

Webアプリケーションフレームワークの作り方 in Python — c-bata.link の資料が最近になってホットエントリー入りし、多くの方に読んでいただけています。実はあの資料途中で執筆に飽きちゃって雑に書き上げて放置していたのですが、最近読まれてるみたいなのでとりあえずルーティングの章まではちゃんと書き直しました。続きも気が向いたときに資料を修正しておきます。

そんな中でWSGIサーバーを作りながらHTTPについて学べる章があってもいいかもとふと思いました。 書くとすれば内容的には id:shimizukawa さんのPyCon JP 2018の発表をもう少し詳しく説明する資料になりそうな気がします。

PyCon JP 2018: Webアプリケーションの仕組み - 清水川のScrapbox

とはいえ自分もWSGIサーバーを一度も書いたことがないので、気分転換にシンプルなWSGIサーバーを書いてみました。 4時間ぐらいかかるかなと思いながら id:shimizukawa さんの上記の資料のコードをぱくりつつ書いてみたら、1時間ちょっとで最低限動くものが出来ました。その後結局リファクタリングのためにwsgirefやgunicornの実装読んでたら5時間ぐらい使ったのですが、今は手元のDjangoアプリケーションを動かしてみてもPOSTのエンドポイントとか含めてちゃんと動いてくれました。

100行ちょっと超えた程度の実装に落ち着いたので解説もそれほど大変ではなさそう。パフォーマンスの改善やヘッダーを異常に長くしてサーバー詰まらせるようなリクエストの対策などを考えだすと課題はいくらか残っていますが解説用にはちょうどいい実装かなと思います。

ソースコードはこちら

import socket
import urllib.parse
from threading import Thread


class ResponseWriter:
    def __init__(self):
        self.headers = None
        self.status_code = None

    def start_response(self, status_code, headers):
        self.status_code = status_code
        self.headers = headers

    @property
    def called(self):
        return self.headers is not None and self.status_code is not None


def worker(conn, wsgi_app, env):
    with conn:
        response = ResponseWriter()
        wsgi_response = wsgi_app(env, response.start_response)
        print(f"{env['REMOTE_ADDR']} - {response.status_code}")
        if not response.called:
            conn.sendall(b'HTTP/1.1 500\r\n\r\nInternal Server Error\n')
            return

        status_line = f"HTTP/1.1 {response.status_code}".encode("utf-8")
        headers = [f"{k}: {v}" for k, v in response.headers]

        response_body = b""
        content_length = 0
        for b in wsgi_response:
            response_body += b
            content_length += len(b)
        headers.append(f"Content-Length: {content_length}")
        header_bytes = "\r\n".join(headers).encode("utf-8")
        env["wsgi.input"].close()  # this doesn't raise error if close twice.
        conn.sendall(status_line + b"\r\n" + header_bytes + b"\r\n\r\n" + response_body)


class WSGIServer:
    def __init__(self, app, host="127.0.0.1", port=8000,
                 max_accept=32, timeout=30.0, rbufsize=-1):
        self.app = app
        self.host = host
        self.port = port
        self.max_accept = max_accept
        self.timeout = timeout
        self.rbufsize = rbufsize

    def make_wsgi_environ(self, rfile, client_address):
        # should return '414 URI Too Long' if line is longer than 65536.
        raw_request_line = rfile.readline(65537)
        method, path, version = str(raw_request_line, 'iso-8859-1').rstrip('\r\n').split(' ', maxsplit=2)
        if '?' in path:
            path, query = path.split('?', 1)
        else:
            path, query = path, ''

        env = {
            'REQUEST_METHOD': method,
            'PATH_INFO': urllib.parse.unquote(path, 'iso-8859-1'),
            'QUERY_STRING': query,
            'SERVER_PROTOCOL': "HTTP/1.1",
            'SERVER_NAME': socket.getfqdn(),
            'SERVER_PORT': self.port,
            'REMOTE_ADDR': client_address,
            'SCRIPT_NAME': "",
            'wsgi.version': (1, 0),
            'wsgi.url_scheme': "http",
            'wsgi.multithread': True,
            'wsgi.multiprocess': False,
            'wsgi.run_once': False,
        }

        while True:
            # should return '431 Request Header Fields Too Large'
            # if line is longer than 65536 or header exceeds 100 lines.
            line = rfile.readline(65537)
            if line in (b'\r\n', b'\n', b''):
                break

            key, value = line.decode('iso-8859-1').rstrip("\r\n").split(":", maxsplit=1)
            value = value.lstrip(" ")
            if key.upper() == "CONTENT-TYPE":
                env['CONTENT_TYPE'] = value
            if key.upper() == "CONTENT-LENGTH":
                env['CONTENT_LENGTH'] = value
            env_key = "HTTP_" + key.replace("-", "_").upper()
            if env_key in env:
                env[env_key] = env[env_key] + ',' + value
            else:
                env[env_key] = value
        env['wsgi.input'] = rfile
        return env

    def run_forever(self):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, True)
            sock.bind((self.host, self.port))
            sock.listen(self.max_accept)

            while True:
                conn, client_address = sock.accept()
                conn.settimeout(self.timeout)
                rfile = conn.makefile('rb', self.rbufsize)
                env = self.make_wsgi_environ(rfile, client_address)
                Thread(target=worker, args=(conn, self.app, env), daemon=True).start()


if __name__ == '__main__':
    from main import app
    serv = WSGIServer(app)
    serv.run_forever()

動かすとWSGIアプリケーションから渡されたヘッダーも正しく返しているので、下のようにリダイレクトとかも問題なく動いている。

f:id:nwpct1:20180923214828p:plain

最近かなりブログの更新頻度が落ちていたので、いつもより雑なネタですが出してみました。

2018/09/23 23:33:00 追記

この記事で紹介した実装は、socketからreadする長さが4096バイトの整数倍のときに、次の書き込みを期待する実装になっているのでブロックする。ちゃんとハンドリングするならおそらく select とか epoll 使うのが筋ですが、清水川さんの発表資料はトークの対象レベルが結構低めなので非同期I/Oの話を入れられなかったぽい。気が向いたらこっちのサンプルはselect使って修正します。

wsgirefの実装読んでたら、makefileを使ったシンプルな解決策に気づいたのでこの問題修正済みです。

追記おわり

2018/09/24 04:10:00 追記

該当行(wsgiref)

追記おわり

2018/09/25 13:10:00 追記

追記おわり

PyCon JP 2018 でDjangoの話をしてきました。

まずはPyCon JP 2018おつかれさまでした。全体的にクオリティの高い発表が多く、参加者としても楽しむことができました。 また最近は執筆を優先していた結果Pythonの勉強会にあまり顔を出せていなかったため、久しぶりにご挨拶できた方も多かったです。

自分の発表について

Djangoの本を執筆中ということもあり、 DjangoCongress JP 2018 に続いて今回もDjangoのネタで登壇しました。 採択された「Django REST Framework におけるAPI実装プラクティス」の他に、もう一つDjangoの行っているセキュリティ対策についてデモを交えながら解説するトークもCfPを出していたのですがこちらは補欠となりました。間違えて30分枠にしてしまったこともありもし繰り上がっても断る予定だったので資料は作りませんでしたが、執筆中の本にはXSSCSRF、Click JackingやSQL Injectionの解説とデモを入れているので発売を楽しみに待っていてください。


03-102_Django REST Framework におけるAPI実装プラクティス(芝田 将)

ページネーションやAPIのRate limitingの実装、トークン認証、その他のtipsを紹介しました。

ちなみに本題とは関係ないのですが、今見返すとページネーションのところで紹介した複合インデックスの順番を間違えていました、すいません。 ALTER TABLE snippets ADD INDEX ix_created_public(created_at, is_public) よりも ALTER TABLE snippets ADD INDEX ix_public_created(is_public, created_at) のほうがスキャン数が少なくクエリを効率よく実行できるかなと思います。

自分の発表時間になると非常に多くの方が聞きにきてくださって、嬉しかったです。

発表後にお話した方々からは多くポジティブなフィードバックをいただきました。Tweetしてくださった方々も含めて感想くださった方ありがとうございます。 ただ個人的には聞きに来てくれたみなさんの役に立つ話をすることはできたかなとは思いますが、カンファレンスというよりは勉強会のネタみたいな話になってしまったかなという反省もあります。トークのまとまりやカンファレンスらしさから考えると2年前のWSGIフレームワークの発表のほうがよほど優れていました。

PyCon JP 2016の発表

発表はぎこちなく、ところどころ用語がおかしかったりもしましたが、それでも話の作り方や網羅性は今みても今年の発表よりよくできている気がします。 スライドについたはてぶの数も今回より当時のほうが多く、Youtubeの再生数やupvote数、当時のTweetの様子 (Togetterまとめ)を見てみても、2年前の発表のほうが多くの反響がありました。

c-bata.link

またスライドと一緒に公開していた補足資料が何故か昨日からバズっていたらしく350はてぶを越えていて驚きました。 2年前に書いた内容ですが、今でもWSGIフレームワークに関する日本語の資料の中では一番まとまっていると思っています。 Python3.7 の機能も使いつつ資料と実装をブラッシュアップして話すこともできるので、再演依頼の方お待ちしてます。

次回プロポーザルを出すときは、この2年前の発表にまけないカンファレンスにふさわしい発表にしようと思います。

個人的によかった発表

全体的にクオリティの高いセッションが多い印象でしたが、当日聴講したり、後から動画でチェックしたセッションの中で印象に残った発表はこちらです。

  • JVM上で動くPython3処理系cafebabepyの実装詳解 - 澁谷 典明
  • Pythonでざっくり学ぶUnixプロセス - tell-k
  • Webアプリケーションの仕組み - Takayuki Shimizukawa
  • あなたと私いますぐパッケージング - Atsushi Odagiri
  • 実践・競馬データサイエンス - 貫井 駿
  • Djangoだってカンバンつくれるもん(Django Channels + Vue) - denzow

とても勉強になりました。ありがとうございます。

感想

全体的にDjangoの注目度が高かったことが個人的にはうれしかったです。 今執筆中の本も本当に面白いものになってきているのでもう少々お待ち下さい。

最後になりましたが、スタッフのみなさんも準備ありがとうございました。

エキスパートPythonプログラミング 改訂2版 (アスキードワンゴ)

エキスパートPythonプログラミング 改訂2版 (アスキードワンゴ)