個人的なグラフ描画用のパッケージに機能を追加した

github.com

個人的なグラフ描画用のパッケージに機能を追加した。その前にこのutataneというパッケージが何をするものなのかを整理してみる気になった。 何をするものなのかというと、グラフを描く等に関する忌避感を緩和するためのもの。だるいと思う率を下げたい感じ。

グラフを描くだるさ

グラフを書くだるさには2つある

  • matplotlib内の話
  • matplotlib外の話

前者はmatplotlib(pyplot)が状態を持っていてつらいという話。もっときれいにわかりやすくコードが書けるのでは?という話。 これはこれで魅力的ではあるのだけれど。今回のスコープの対象外。

後者の方が改善対象。

matplotlibの外の話

前回の記事でも少し触れたけれどグラフの利用方法は2つある(まだ2つしか考えていないという意味)。

  • グラフの表示(show)
  • グラフを画像として保存(dump)

そして何らかのコードが実行され、その結果に対して上の2つのどちらかのaction(show,dumpの内のどちらか)が行われるという風に捉えていた。

00plot.py

from utatane import as_command


@as_command
def main(plt):
    xs = list(range(1, 11))
    ys = list(map(lambda x: x * x, xs))
    plt.plot(xs, ys)

このコードの中にmatplotlibの外のだるさが存在する。さてここで f(x) = x * x という関数ではなく g(x) = math.sqrt(x) のような関数の値をplotしたくなったらどうするか?あるいは[1,11]の範囲ではなく[1,20]の範囲で表示したくなったらどうするかという話がある。

データの収集部分とデータの描画部分を分ける

先程のだるさを考えて見るに、データの収集部分は描画部分から切り離しておきたいという気持ちが生まれてくる。ところで、データの収集部分とデータの描画部分を分ける試みは結構普通のことでmatplotlibのexamplesでも行われている(e.g. レーダーチャートの例とか。収集部分はexample_data()という関数に切り出している)。

真似してみると以下の様な形。

01plot.py

from utatane import as_command


def example_data(f):
    xs = list(range(1, 11))
    ys = list(map(f, xs))
    return [xs, ys]


@as_command
def main(plt):
    data = example_data(lambda x: x * x)
    plt.plot(data[0], data[1])

たしかにデータの収集部分と描画部分を分けてあげるなら見通しは良くなっている気がする。

でも、例えばここで他の関数を適用した結果をplotしたいとなったらどうだろう?結局ほとんどコピペした01plot2.pyのようなものを作る必要が出てくる。

yield_fixtureの話

ところで現在作っている環境ではちょっと違う形で依存するデータを渡している。pytestのfixtureに擬えてグラフに必要な部分をfixtureと呼ぶことにする。そしてそのfixtureはyield_fixtureというデコレータが付与された関数で計算される。結果は引数となって注入される。以下の様な感じ。

02usefixture.py

from utatane import as_command, yield_fixture


@yield_fixture
def data():
    f = lambda x: x * x  # NOQA
    xs = list(range(1, 11))
    ys = list(map(f, xs))
    yield {"data": [xs, ys]}


@as_command
def render(plt, data):
    plt.plot(data[0], data[1])

ちょっと、f()に当たる部分をどの位置におけば良いのかまだ完全にはイメージがついていないので少しコードは変わっているけれど。以下の様に考えると良い感じ。

  • yield_fixture付きの関数によってグラフに必要なデータ(fixture)が収集される
  • as_command付きの関数はグラフの描画を行う関数

何が言いたいかというと、main()という名前の関数ではなくrender()という名前の関数にしたことも関係するのだけれど、utatane.as_commandというのは、グラフの描画の処理を、コマンドラインアプリケーションに持ち上げる(変換する)修飾子(decorator)ということ。

そのように考えると何が嬉しいのかというと、グラフの描画関数に引数として渡されるfixture部分は何かという意味合いを決める事ができる。例えばyield_fixtureで計算された値はデフォルトの依存値として扱うと良さそうという風に。そしてas_commandによって変換されたアプリケーションはこの依存値をどうするかということを考えてみると、コマンドライン引数として扱うというのが自然な気がしてきた。

つまりこういう形。

$ python 02usefixture.py -h
usage: 02usefixture.py [-h] [--data DATA] {dump,show} ...

positional arguments:
  {dump,show}

optional arguments:
  -h, --help   show this help message and exit
  --data DATA

yield_fixture でfixtureとして扱われて欲しい値はコマンドライン引数として受け取れるようになる。例えばjson,yamlとか拡張子を見て良い感じに値を取り出してあげれば良さそう。すると何が嬉しいかというと、無引数ではどのような表示になるかのサンプルを見ることができ、引数を与えてバッチスクリプトのようにグラフを生成するコマンドとして扱うことができるようになる。fixtureという形で依存値を切り出しておいたのでこういうことが出来るようになった。

こういう感じに。

$ python 02usefixture --data data0.json dump data0.png
$ python 02usefixture --data data1.json dump data1.png
$ python 02usefixture --data data2.json dump data2.png

もちろん、その時々でのグラフを単に表示して確認したい場合にはshowすれば良い。

$ python 02usefixture --data data0.json

サンプル

サンプルはこの辺に書いた

例えば、元々テキトウな関数をplotする例を書いておき

$ python plot.py

without-data

その後、dict,set,list毎のメモリー消費量の様な値をplotしてみるというように

$ python gen.py > data.json
$ python plot.py --data data.json

with-data

この時のコードはこういう感じ

plot.py

import math
from utatane import as_command, yield_fixture


@yield_fixture
def data():
    data = [
        [(i, i) for i in range(1, 11)],
        [(i, i * i) for i in range(1, 11)],
        [(i, math.sqrt(i)) for i in range(1, 11)],
    ]
    yield {"data": {"values": data, "labels": ["i", "i*i", "sqrt(i)"]}}


@as_command
def render(plt, *, data):
    for label, rows in zip(data["labels"], data["values"]):
        xs = [row[0] for row in rows]
        ys = [row[1] for row in rows]
        plt.plot(xs, ys, label=label)

pytestのyieldで指定するfixtureのやつを真似して実装してみた

github.com

pythonのyieldで指定するfixtureのやつを真似して実装してみた。意外と簡単にできるし。面白いし。便利な気がする。

yield fixture?

pytestのfixtureはsetup/teardownをgeneratorで定義できる。昔はyield_fixtureという名前だったけれど。現在はfixture自体がその機能を保持するようになった。

pytest supports execution of fixture specific finalization code when the fixture goes out of scope. By using a yield statement instead of return, all the code after the yield statement serves as the teardown code

pytest fixtures: explicit, modular, scalable — pytest documentation

fixture((テストの)実行に必要となる値)の後始末も含めてgeneratorの形式で記述できるので便利。

smtpのclientを終わったらcloseしている。

import smtplib
import pytest

@pytest.fixture(scope="module")
def smtp():
    smtp = smtplib.SMTP("smtp.gmail.com", 587, timeout=5)
    yield smtp  # provide the fixture value
    print("teardown smtp")
    smtp.close()

こういう風に書いてあげると以下の様な形で呼んでくれる。yield部分が間に挟まる処理でpytestの場合はtest部分。

setup    -- smtp
  test0
  test1
  test2
teardown -- smtp

また、yieldに渡した値はtest関数で使えるようになる。

def test(smtp):
    do_something()

この機能に似たような機能を作ってみようという話。

作ったものの使い方の話

作ったものは完全にpytestのそれと互換性のあるものではないし。pytestのコードを実際に確認して作ったわけではないので同じ実装になっているというわけではないけれど。

以下の様にして使う。

from yieldfixture import create
run, yield_fixture = create()


@yield_fixture
def f():
    print(">>> f")
    yield 1
    print(">>> f")


@yield_fixture
def g():
    print("  >>> g")
    yield 2
    print("  >>> g")


@run
def use_it(x, y):
    print("{} + {} = {}".format(x, y, x + y))

この実行結果は以下のようになる。

>>> f
  >>> g
1 + 2 = 3
  >>> g
>>> f

f,gとfixtureとして指定したのでその順に呼ばれている。runデコレータをつけた関数は実行されるので注意。 特に指定がない限りyield_fixtureデコレータをつけた関数はfixtureとして登録(指定)される。 yieldで渡された値が関数の引数として渡される。

(pytestのfixtureと違ってscopeオプションには対応していない。pytestの場合はscope=“module"などとするとそのmodule内で一度だけ実行みたいなことができる)

context

実行結果を見て分かる通り、最後の1 + 2 = 3という部分だけインデントされていない。この部分でもインデントをつけたい。そのためには何らかの方法で情報を受け渡せる必要がある。このような時にcontextを使う。

contextはargsとkwargsを持ったもの。positional argumentsとkeyword argumentsは適宜使い分けられる。

ctx = Context([0,1,2,3], {"foo": "bar"})

ctx[3] # => ctx.args[3]
ctx["foo"] # => ctx.kwargs["foo"]

これを利用するには with_context デコレータを一緒につける。残念ながらデコレータをつける順序を気にする必要があるので注意。

from yieldfixture import create, with_context
run, yield_fixture = create()


@yield_fixture
@with_context
def f(ctx):
    i = ctx["i"] = 0
    print("{}>>> f".format("  " * i))
    yield 1
    print("{}>>> f".format("  " * i))


@yield_fixture
@with_context
def g(ctx):
    i = ctx["i"] = ctx["i"] + 1
    print("{}>>> g".format("  " * i))
    yield 2
    print("{}>>> g".format("  " * i))


@run
def use_it(x, y, *, i=0):
    print("{}{} + {} = {}".format("  " * (i + 1), x, y, x + y))

contextに渡された値は関数の引数にキーワード引数として渡される。そんなわけで今度はindentを記録して使う事が出来るようになった。

>>> f
  >>> g
    1 + 2 = 3
  >>> g
>>> f

途中で発生する例外に対して安全なfixtureの書き方

特にtestでの利用を想定して作ったわけではないけれど。test時のsetup/teardownのことを考えると、途中のコードが失敗した時にも必ずteardown部分が実行されて欲しいということがあるかもしれない。この場合にはtry-finallyで括ってあげるのが無難(たぶんpytestの方もそうなはずだけれど確認していない(追記: 嘘でしたpytestの方try-finallyで囲まなくても大丈夫です))。

追記: 以下の部分のtry-finallyは不要です。0.2.0から不要になりました(詳細はコメント欄のリンク先の記事参照)。

from yieldfixture import create, with_context
run, yield_fixture = create()


@yield_fixture
@with_context
def f(ctx):
    i = ctx["i"] = 0
    print("{}>>> f".format(" " * i))
    try:
        yield 1
    finally:
        print("{}>>> f".format(" " * i))


@yield_fixture
@with_context
def g(ctx):
    i = ctx["i"] = ctx["i"] + 1
    print("{}>>> g".format(" " * i))
    try:
        yield 2
    finally:
        print("{}>>> g".format(" " * i))


@run
def use_it(x, y, *, i=0):
    print("{}{} + {} = {}".format(" " * (i + 1), x, y, x + y))
    1 / 0

途中でエラーになってもteardown部分(yield以降のコード)が実行される。

>>> f
 >>> g
  1 + 2 = 3
 >>> g
>>> f
Traceback (most recent call last):
  File "qr_88388ez.py", line 28, in <module>
    def use_it(x, y, *, i=0):
  File "$HOME/vboxshare/venvs/my3/sandbox/daily/20170722/example_yield_fixture/yieldfixture.py", line 98, in run_with
    return fn(*ctx.args, **ctx.kwargs)
  File "qr_88388ez.py", line 30, in use_it
    1 / 0
ZeroDivisionError: division by zero

利用するfixtureを直接指定する

利用するfixtureを直接指定することもできる。個人的にはこれができたのがかなり嬉しい感じだった。例えば、通常ならf,gと実行されるところをg,fと実行されるようにしたり、あるいはfだけを実行したりみたいなことが出来る。

from yieldfixture import create, with_context
run, yield_fixture = create()


@yield_fixture
@with_context
def f(ctx):
    i = ctx["i"] = ctx.get("i", -1) + 1
    print("{}>>> f".format("  " * i))
    try:
        yield 1
    finally:
        print("{}>>> f".format("  " * i))


@yield_fixture
@with_context
def g(ctx):
    i = ctx["i"] = ctx.get("i", -1) + 1
    print("{}>>> g".format("  " * i))
    try:
        yield 2
    finally:
        print("{}>>> g".format("  " * i))


@run
def use_it(x, y, *, i=0):
    print("{}{} + {} = {}".format("  " * (i + 1), x, y, x + y))


@run([g, f])
def use_it2(x, y, *, i=0):
    print("{}{} + {} = {}".format("  " * (i + 1), x, y, x + y))

2つ目のrunではfixtureの実行の順序が異なる。

>>> f
  >>> g
    1 + 2 = 3
  >>> g
>>> f
>>> g
  >>> f
    2 + 1 = 3
  >>> f
>>> g

実装の話

この機能自体の実装はそれほどたいへんではなかったりする。ちょっとだけ話したいので実装について話をすると、この機能は、任意長のcontext managerを順々にネストさせて実行する方法がわかれば実装できる。

任意長のcontext managerと言うのはこういう話。

例えば、f,g,hという順にfixtureが登録されたとして以下の様に実行されて欲しい。

setup -- f
  setup -- g
    setup -- h
      do something
    teardown -- h
  teardown -- g
teardown -- f

1つだけで良いのならcontextlib.contextmanager

ここで f にだけ考えると、contextlib.contextmanagerを使ってそのまま実装できる。

import contextlib

@contextlib.contextmanager
def f():
    # setup
    print("do something for setup")
    yield
    # teardown
    print("do something for teardown")

関数を自動的にwithで使えるcontext managerにしてくれるので便利。

with f():
    print("hello")


# do something for setup
# hello
# do something for teardown

context manager自体は、例えばrequestを投げて返ってきたresponseをcloseしたいみたいな時にも使われる。

@contextlib.contextmanager
def closing(response):
    try:
        yield response
    finally:
        response.close()

class Response:
    def __init__(self):
        print("open")

    def close(self):
        print("close")


with closing(Response()) as response:
    print(response)

# open
# <__main__.Response object at 0x105e54128>
# close

ちなみに同様の機能を持った関数は contextlib.closing() として存在していたりはする。

複数の任意個のcontext managerを扱うには?

複数の任意個のcontext managerを扱うにはどうするかというのが実装に関しては本題。

例えば、f,g,hというcontext managerがあったとしてこれを愚直に手で書くのならネストした記述も問題ない。

with f():
    with g():
        with h():
            print("do something")

これを例えば nested([f, g, h]) などという感じの呼び出しで実現する nested() を定義したい。

1つの方法は再帰すること。実装は意外と素朴。呼び出し関係の維持はそのままwith syntaxがやってくれる。

@contextlib.contextmanager
def nested(cms):
    if not cms:
        yield
    else:
        with cms[0]():
            with nested(cms[1:]):
                yield

ネストしたcontext managerの呼び出しの挙動が分かればまぁ直感的な気がする。

例えば以下の様なコードもしっかりネストされる(setupが全部呼び終わった後にteardownが逆順で呼ばれる)。

import contextlib


@contextlib.contextmanager
def f():
    # setup
    print("setup f")
    yield
    # teardown
    print("teardown f")


@contextlib.contextmanager
def g():
    # setup
    print("setup g")
    yield
    # teardown
    print("teardown g")


@contextlib.contextmanager
def h():
    # setup
    print("setup h")
    yield
    # teardown
    print("teardown h")


with nested([f, g, h]):
    print("** do something**")

# setup f
# setup g
# setup h
# ** do something**
# teardown h
# teardown g
# teardown f

もう1つは contextlib.ExitStack を使うこと。再帰など考えなくて良いので使う分にはこちらのほうが良いのかもしれない。 (ちなみに今回作ったyieldfixtureでは直接再帰を書いている)

@contextlib.contextmanager
def nested(cms):
    with contextlib.ExitStack() as s:
        for cm in cms:
            s.enter_context(cm())
        yield

これも同様に動く。

with nested([f, g, h]):
    print("** do something**")

# setup f
# setup g
# setup h
# ** do something**
# teardown h
# teardown g
# teardown f

補足的な話

これは全く脇道にそれた話題で補足的な話しになるけれど。contextlib.ExitStack は goのdefer的な感覚で使えるかもしれない。callbackを渡せるのでdeferと同様の感覚で実行するクロージャや関数を指定してあげれば良い。

import contextlib


class Response:
    def __init__(self, name):
        self.name = name
        print("open", self.name)

    def close(self):
        print("close", self.name)


with contextlib.ExitStack() as s:
    response = Response("1")
    s.callback(response.close)
    response = Response("2")
    s.callback(response.close)

    print("use", response.name, response)
    print("use", response.name, response)

closeが渡された順序と逆順に実行されていく(これもdeferとおんなじ感じ)。

open 1
open 2
use 2 <__main__.Response object at 0x10c7c4748>
use 2 <__main__.Response object at 0x10c7c4748>
close 2
close 1

ところで例外が起きた時にはどうなるかというと。

with contextlib.ExitStack() as s:
    response = Response("1")
    s.callback(response.close)
    response = Response("2")
    s.callback(response.close)

    print("use", response.name, response)
    1 / 0  # ZeroDivisionError !!!!!!11
    print("use", response.name, response)

大丈夫。

open 1
open 2
use 2 <__main__.Response object at 0x104d83710>
close 2
close 1
Traceback (most recent call last):
  File "qr_8838JFK.py", line 20, in <module>
    1 / 0
ZeroDivisionError: division by zero

これとは関係なく思ったこと

これとは関係なくこのパッケージを作っている途中で思ったことを幾つか。

  • unit test を書くのが面倒だったので integration testしか書いていない
  • readmeにjinja2のtemplate(kamidana)を使うのが便利だった

integration testの話

1つ目に関してわりとひどくってintegration testといっていてもやっていることは出力を確認しているだけ。

import os.path
import unittest
import textwrap
import contextlib

@contextlib.contextmanager
def _capture():
    from io import StringIO
    buf = StringIO()

    with contextlib.redirect_stdout(buf):
        yield buf

class Tests(unittest.TestCase)
    def test_it__with_context(self):
        with _capture() as buf:
            with open(os.path.join(os.path.dirname(__file__), "../../examples/01usecontext.py")
                      ) as rf:
                exec(rf.read())

        expected = textwrap.dedent(
            """
        >>> f
          >>> g
            1 + 2 = 3
          >>> g
        >>> f
        """
        )
        self.assertEqual(buf.getvalue().strip(), expected.strip())

しかも内部ではexamplesのファイルを実行(exec)しているだけ。ひどい。ただ contextlib.redirect_stdout で標準出力を奪えるので出力結果を確認するようなテストを書くには便利かもしれない。

(ちなみにopenを使っている辺りでふしぎな位置に改行があるのはyapfでフォーマットを整えているせい(既にソースコードのフォーマットは人間が逐一気にして管理するものじゃない気がするので黙って受け入れている))

readmeの話

readmeは利用例(example)だけで十分

readmeにモチベーションとか機能とか書かれていても使い方がわかんないとどうしようもないし。APIドキュメントに価値をあまり見出していない人間なので。コードの利用例(example)以外に興味がない。そしてexampleを列挙できない程度に高機能なライブラリは真面目にドキュメントを作らないとだめと言うような認識(そしてそのような高機能なライブラリはほとんどライフワークになる程度に維持管理や保守がつらいのであんまり作りたくない)。

そんなわけで、readmeでは、exampleを列挙して終わりにしようというのが最近の結論なのだけれど。exampleを書くのがわりとだるい。そしてmarkdownやReST形式のファイルの中に逐一コード例をコピペするのも馬鹿馬鹿しいのでjinja2のテンプレートを使って生成することにした。

example書くの意外とだるい

特定のファイル(examples/以下のファイル)を読み込んだ結果を埋め込むというようなことができれば良い感じ。真面目にドキュメントを作るならsphinxを使うだろうし、sphinxではliteralincludeみたいなディレクティブが存在するので便利に外部のファイルを埋め込めるのだけれど。さすがにreadmeなので頑張りたくないという気持ちがあった。

実際に使ったのはkamidanaというjinja2-cli(j2-cli)の亜種みたいなものなのだけれど。やりたいことはほとんどどのようなテンプレートエンジンでも出来ると思う。

こんな感じでmisc以下にreadme.j2というファイルを作ってreadmeを生成している。ReSTの形式でreadmeを作っているのでindent(こちらはjinja2に組み込み)のフィルターが便利だった。

yieldfixture
========================================

.. image:: https://travis-ci.org/podhmo/yieldfixture.svg?branch=master
    :target: https://travis-ci.org/podhmo/yieldfixture

how to use
----------------------------------------

.. code-block:: python

  {{"examples/00simple.py"|read_from_file|indent(2, False)}}

output

.. code-block::

  {{"python examples/00simple.py"|read_from_command|indent(2, False)}}

コード例とコード例の実行結果をreadmeに埋め込んでいる。readmeの生成自体は以下のような単純なMakefileでやっている。

readme:
  kamidana -a kamidana.additionals.reader misc/readme.j2 | sed 's@${HOME}@$$HOME@g' > README.rst

pytestのfixtureとcontextlib.contextmanagerでの例外の取り扱い方の違い

前回の記事でpytestのfixtureでもteardownが実行されることを確実にするにはtry-finallyで囲む必要があるという風に書いてしまっていた。

特にtestでの利用を想定して作ったわけではないけれど。test時のsetup/teardownのことを考えると、途中のコードが失敗した時にも必ずteardown部分が実行されて欲しいということがあるかもしれない。この場合にはtry-finallyで括ってあげるのが無難(たぶんpytestの方もそうなはずだけれど確認していない)

これが嘘という話

pytestでは正しくteardownが実行できている

例えば以下のようなコードをpytestで実行した時に、after部分のprintがちゃんと呼ばれる。

import pytest


@pytest.fixture
def ob():
    ob = object()
    print("")
    print("<<< before", id(ob))
    yield ob
    print(">>> after", id(ob))


def test_it(ob):
    print("**test", id(ob), "**")
    1 / 0

>>> after 4375605680 という出力が行われている。

pytest -s .
================================================= test session starts =================================================
platform darwin -- Python 3.5.2, pytest-3.0.2, py-1.4.31, pluggy-0.3.1
rootdir:$HOME/vboxshare/venvs/my3/sandbox/daily/20170722/example_pytest, inifile:
collected 1 items

02exception/test_it.py
<<< before 4375605680
**test 4375605680 **
F>>> after 4375605680


====================================================== FAILURES =======================================================
_______________________________________________________ test_it _______________________________________________________

ob = <object object at 0x104ce71b0>

    def test_it(ob):
        print("**test", id(ob), "**")
>       1 / 0
E       ZeroDivisionError: division by zero

02exception/test_it.py:15: ZeroDivisionError
============================================== 1 failed in 0.05 seconds ===============================================

contextlibの実装の詳細

contextlib.contextmanagerは内部で_GeneratorContextManagerというクラスが使われているのだけれど。このクラスの __exit__() の定義が期待していたものとは違っていた。以下のような定義(長いし難しいので全部読む必要は無い)。

class _GeneratorContextManager(ContextDecorator):
    def __exit__(self, type, value, traceback):
        if type is None:
            try:
                next(self.gen)
            except StopIteration:
                return
            else:
                raise RuntimeError("generator didn't stop")
        else:
            if value is None:
                # Need to force instantiation so we can reliably
                # tell if we get the same exception back
                value = type()
            try:
                self.gen.throw(type, value, traceback)
                raise RuntimeError("generator didn't stop after throw()")
            except StopIteration as exc:
                # Suppress StopIteration *unless* it's the same exception that
                # was passed to throw().  This prevents a StopIteration
                # raised inside the "with" statement from being suppressed.
                return exc is not value
            except RuntimeError as exc:
                # Likewise, avoid suppressing if a StopIteration exception
                # was passed to throw() and later wrapped into a RuntimeError
                # (see PEP 479).
                if exc.__cause__ is value:
                    return False
                raise
            except:
                # only re-raise if it's *not* the exception that was
                # passed to throw(), because __exit__() must not raise
                # an exception unless __exit__() itself failed.  But throw()
                # has to raise the exception to signal propagation, so this
                # fixes the impedance mismatch between the throw() protocol
                # and the __exit__() protocol.
                #
                if sys.exc_info()[1] is not value:
                    raise

context managerについて

実際のコードの内容に触れる前におさらいが必要かも。

context managerの機能についておさらいしておくと(詳しくはここ)、

  • context managerとして機能するオブジェクトは __enter__()__exit__(exc_type, exc_value, traceback) のmethodを持ったもの
  • 内部で例外が発生しない場合には、__exit__()の引数は全部None
  • 内部で例外が発生した場合に、__exit__() の戻り値がtruthyな値の場合には発生した例外が無視される

実際に読むべき重要なところ

読むのは例外が発生した時なので、最初のifはelse部分、valueも通常は入っているはず。まぁそんなわけで真面目に読むべきはここの部分。

                self.gen.throw(type, value, traceback)
                raise RuntimeError("generator didn't stop after throw()")

ここでself.genは渡された関数の生成するgeneratorが束縛されている(generator関数は呼び出す際に戻り値としてgenerator objectを返す)。 そしてgenerator.throw()は、ほとんど __exit__()と同じ引数をを取り、そのgeneratorの現在の位置情報を利用して例外を送出する。

例えば雑な例をあげてみる。以下の様なコードは以下のような出力を返す。

def gen():
    print("hai")
    yield 1
    print("hoi")


it = gen()
print(next(it))
it.throw(Exception, Exception("hmm"))

nextでyield 1までは実行された後に、throw()を呼ぶ(tracebackは渡していないけれど。たぶん良い感じのネストしたerror reportに使われるかexceptionの発生位置として使われる)。

hai
1
Traceback (most recent call last):
  File "qr_8838MIf.py", line 9, in <module>
    it.throw(Exception, Exception("hmm"))
  File "qr_8838MIf.py", line 3, in gen
    yield 1
Exception: hmm

pytestの方の実装

pytestの方はというとfixtures.pyとrunner.pyのあたりをみれば良いのだけれど。雑にnext()を呼んでいる。この辺(紹介は雑)

fixtures.py

def call_fixture_func(fixturefunc, request, kwargs):
    yieldctx = is_generator(fixturefunc)
    if yieldctx:
        it = fixturefunc(**kwargs)
        res = next(it)

        def teardown():
            try:
                next(it)
            except StopIteration:
                pass
            else:
                fail_fixturefunc(fixturefunc,
                    "yield_fixture function has more than one 'yield'")

        request.addfinalizer(teardown)
    else:
        res = fixturefunc(**kwargs)
    return res

runner.py

    def _callfinalizers(self, colitem):
        finalizers = self._finalizers.pop(colitem, None)
        exc = None
        while finalizers:
            fin = finalizers.pop()
            try:
                fin()
            except Exception:
                # XXX Only first exception will be seen by user,
                #     ideally all should be reported.
                if exc is None:
                    exc = sys.exc_info()
        if exc:
            py.builtin._reraise(*exc)

簡略化した話

詳細に立ち寄りすぎたので簡略化すると例えば以下の様なコードがあったとする(再掲)。

def ob():
    ob = object()
    print("")
    print("<<< before", id(ob))

    yield ob  # ここで例外が発生した場合の話

    print(">>> after", id(ob))

ここで generator.throw() が呼ばれれば当然yieldの位置までしか実行はされずに終わる。一方でnext()が呼ばれれば通常通りiteratorを進めるだけなので以降の処理が実行される(最終的にStopIterationが送出される)。

雑な対応方法

上のことから考えて雑な対応方法を考えると以下の様な感じになる。

  • なるべく元のcontextlibの実装を利用する
  • 最後まで動かし切るために next() は呼ぶ
class SafeContextManager(_GeneratorContextManager):
    def __exit__(self, type, value, traceback):
        try:
            next(self.gen)
        except StopIteration:
            if type is None:
                return
        else:
            if type is None:
                raise RuntimeError("generator didn't stop")
        return super().__exit__(type, value, traceback)

上の対応方法に従った形で自分用のcontxtlib.contextmanagerを作ってあげれば良い(safe?という修飾は良くない気もするけれど)。

  • どんなときでもnext()は呼ぶ。
  • type is None の場合には、元の実装と同じ形で処理をする
  • 最も末尾で元の__exit__()を呼ぶ。

(元の__exit__()が呼ばれたとき、type is Noneになることはありえない=next()が2度呼ばれるということはありえない)

from functools import wraps

def safecontextmanager(func):
    @wraps(func)
    def helper(*args, **kwds):
        return SafeContextManager(func, args, kwds)

    return helper

おしまい。

個人的なグラフ描画用のパッケージ作りはじめた

github.com

個人的なグラフ描画用のパッケージ作りはじめた。matplotlibのラッパーの様なもの。 元々のモチベーションは以下の記事に書いてある。

結局グラフの表示用のコードとグラフを画像として出力する用のコードが異なるのがつらい感じだった。

コンセプト的な何か

作ったもののコンセプト的な何かは以下のようなもの

  • グラフを表示することとグラフを画像として出力する事がシームレスに行えて欲しい
  • 個人的な初期設定がデフォルトで入っていて欲しい

後々これに加えて以下も加わった。

  • なるべくグラフの表示位置が変わるなら(e.g. subplot)コードとしての見た目の位置も変わって欲しい
  • pytestのyield_fixture的な準備の機能

グラフを表示することとグラフを画像として出力する事がシームレスに行えて欲しい

これは冒頭の説明と同様のもの。描画したグラフを表示させてみて良さそうと思ったら画像として出力したい。 さすがに誰かに作ったグラフを共有するためだけに、グラフを画面に表示させた状態でスクリーンショット取るなどということは避けたい。

こんな感じのコードで大丈夫。

00simple.py

from utatane import as_command


@as_command
def main(plt):
    xs = list(range(10))
    ys = [x * x for x in xs]
    plt.plot(xs, xs, "g", label="x")
    plt.plot(xs, ys, "b", label="x * x")

as_command というデコレータがつくと自動的にコマンドになる。それぞれshow,dumpというサブコマンドが存在する。

$ python 00simple.py -h
usage: 00simple.py [-h] {dump,show} ...

positional arguments:
  {dump,show}

optional arguments:
  -h, --help   show this help message and exit

グラフの表示

デフォルトではshowが実行される。もちろん明示的に python <filename> show でも良い。

00simple.png

上のコードと同様のコードをpyplotだけで描くと以下と同じもの。

import matplotlib.pyplot as plt
plt.style.use("ggplot")

xs = list(range(10))
ys = [x * x for x in xs]
plt.plot(xs, xs, "g", label="x")
plt.plot(xs, ys, "b", label="x * x")

plt.legend()
plt.show()

テーマは常にggplotで良い気がするので自動的に選ばれる様になっている(どうせ個人的な用途なので。毎回書くのが面倒くさくなった)。 plt.show() は不要。

グラフを画像として出力

グラフを画像として出力するにはdumpを使えば良い。名前は変えるかもしれない。

$ python 00simple.py dump /tmp/00simple.svg
save: /tmp/00simple.svg
$ identity
/tmp/00simple.svg SVG 720x540 720x540+0+0 16-bit sRGB 28.3KB 0.000u 0:00.000

ちなみに--width--heightでサイズは変えられる。

$ python 00simple.py dump --width 400 --height 300 /tmp/00simple2.svg
save: /tmp/00simple2.svg
$ identity /tmp/00simple2.svg
/tmp/00simple2.svg SVG 450x338 450x338+0+0 16-bit sRGB 28.1KB 0.000u 0:00.000

(計算にバグがあるっぽい)

グラフの表示位置と連動してコード上でも位置が変わって欲しい

subplot

例えば1枚の図の中に2つのグラフを描くときに、どこからどこまでが1つ目のグラフのものでどこからどこまでが2つ目のグラフが分かるようにしたかった。

こんな感じ。

import numpy as np
from utatane import as_command, subplot


def f(t):
    return np.exp(-t) * np.cos(2 * np.pi * t)


@as_command
def main(plt):
    t1 = np.arange(0.0, 5.0, 0.1)
    t2 = np.arange(0.0, 5.0, 0.02)

    with subplot(plt, nrows=2, ncols=1) as nth:
        with nth(1):
            plt.plot(t1, f(t1), 'bo', t2, f(t2), 'k')

        with nth(2):
            plt.plot(t2, np.cos(2 * np.pi * t2), 'r--')

02multiple_figure.png

nthの部分で2つのグラフがあることが分かる。ちなみにこれと同様のコードがpyplotだけの場合は以下の様な感じになる(一部分だけ)。

plt.subplot(211)
plt.plot(t1, f(t1), 'bo', t2, f(t2), 'k')
plt.subplot(212)
plt.plot(t2, np.cos(2*np.pi*t2), 'r--')

これはpyplotのチュートリアルから持ってきたもの。

このコード自体は4行に過ぎないのででたくさんコードが必要になってつらいというわけではないのだけれど。pyplotは状態を抱えてしまっているのでコードが長くなってくると、結構ていねいに内部的な状態を把握しながら読んでいかなければならなくなってくる。徐々につらくなってくる。

なので状態を持たない方向に変更していきたいとは思ったりはしているものの、とは言え、まだ、pyplotから完全に独立できたというわけでもない。普通にpyplotが内部で使われているので結局やっている事は同じ。

これを作っていて思ったけれど。jupyter notebookなどで試すときにはインデントが少ない方が書きやすいという気持ちが出る気がする。 一方で、通常のエディタ上で書く一繋ぎのコードに関しては、適宜インデントなどで見た目的にも区切った形にしたいという気持ちが芽生えたりするみたい。

あと、初見で211と212という謎のマジックナンバー的な値の意味を把握するのは難しい気がした(慣れれば楽ではあるけれど)。

multiple windows (figures)

あとそれ以外に図を複数表示したい場合にも(1つの画像中に複数のグラフではなく、複数の画像を一気に作成ということ)。

04multi_window.py

from utatane import as_command, window


@as_command
def main(plt):
    xs = range(100)

    with window(plt, 0):
        plt.plot(xs, xs)

    with window(plt, 1):
        plt.plot(xs, [x * x for x in xs])

今度は2つのwindowが表示される形。これもpyplotだけの場合にはインデントがない形になる。

xs = range(100)

plt.figure(0)
plt.plot(xs, xs)

plt.figure(1)
plt.plot(xs, [x * x for x in xs])

好みの問題かもしれない。

もちろん、これを画像として出力する場合には2枚の画像が出力される。

$ python 04multi_window.py dump /tmp/04multi_window.svg
save: /tmp/04multi_window0.svg
save: /tmp/04multi_window1.svg

04multi_window0.png 04multi_window1.png

あと

3D表示もできるらしいのでやってみた。意外とグラフを描き始めてみると面白いかもしれない。

import numpy as np
from utatane import as_command, plot3d
from matplotlib import cm


def func(x, y):
    return x ** 2 + y ** 2


@as_command
def main(plt):
    x = np.arange(-5, 5, 0.05)
    y = np.arange(-5, 5, 0.05)

    X, Y = np.meshgrid(x, y)

    Z = func(X, Y)

    with plot3d(plt) as ax:
        ax.plot_surface(X, Y, Z, cmap=cm.coolwarm)

06plotting_3d_surface.png

pythonのastモジュールに不満がでたらlib2to3のコードを使ってみよう

はじめに

pythonのコードをparseするためにastモジュールが用意されていたりする。 このモジュールはpythonのコードをparseしてvisitor的なものでastをtravarseしてなどと便利ではあるのだけれど。 コメントなどの情報が消えてしまうなどの不満が出ることがある。 このような時にlib2to3用のコードを使ってみると良いのではないかという話。

lib2to3?

2to3というツールがあったりする。これはpython2.x用のコードをpython3.x用のコードに変換してくれるツール。 よく考えてみて欲しいのだけれど、2to3によってコメントの情報が失われることはない。そして2to3もおおよそASTを取り出してからの変換ということになっているはず。ということは2to3の内部のコードを覗いてみればコメントなどの情報を失うことなくAST変換を行う術が分かるはず。

何が言いたいかというと、コメント情報などの失われを防ぐためにlexerなどから作るなどということは不要ということ(ちなみに完全にフルでparserを再実装したbaronというものもあったりする。ただもう少し抽象度の高いredbaronから使う事がおすすめされていたりする)。

そして2to3の内部で使われているコードがlib2to3というもの。ちなみにこのlib2to3はyapfというコードフォーマッター(gofmtのようなもの)にも使われていたりする。

ちょっとしたコード変換

試しにlib2to3を利用してちょっとしたコード変換をしてみる。

例えば以下の様なコードがあるとする。

hello.py

def hello():
    # this is comment
    return "hello"

これを以下の様に変換してみる。

def *replaced*():
    # this is *replaced* comment
    return "hello"

こういう感じのコードを書けば良い。

from lib2to3 import pytree
from lib2to3 import pygram
from lib2to3.pgen2 import driver

default_driver = driver.Driver(pygram.python_grammar_no_print_statement, convert=pytree.convert)


def parse(code, parser_driver=default_driver):
    return parser_driver.parse_string(code, debug=True)


with open("hello.py") as rf:
    t =  parse(rf.read())
print(t)
t.children[0].children[1].value = "*replaced*"
t.children[0].children[4].children[1].prefix = "    # this is *replaced* comment\n"
print(t)

treeを直接触っているので何をやっているかはものすごく分かりづらいものではあるけれど。テキトウに関数名やコメント部分に *replaced* という文字列を挿入している。tree自体を文字列として出力するとおおよそそのままpythonコードとして出力されるというのも便利。

ちなみにファイルからtreeを作る際は以下でも良い。

t = default_driver.parse_file("hello.py")

もう少し真面目にするなら

もう少し真面目にするなら、このlib2to3用のASTに対するvisitorを作ってあげると良い。 yapfのpytree_visitor.pyなどが参考になる。

もう少し詳しいことは気が向いたら書くかもしれない。

たくさんのnotebookの内容をコマンドラインから再実行して更新したい

はじめに

たとえばグラフを描画するnotebookをたくさん開いている状態。 ちょっとだけimportしたライブラリに変更があったので実行結果が変わりそうになってしまった。 このようなときに、すべてのipynbファイルを開いてrunallした後にsaveというようなことを手動で行いたくない。

はじめはheadless jupyterのようなものを作ろうとしたけれど。nbconvertで大丈夫だったという話。

nbconvert --execute

jupyterにはnbconvertというコマンドもついてくる。 これは、ipynbファイルを別のフォーマット(例えば、pdfだったりhtmlだったりmarkdownだったり)に変換するためのコマンドという認識だったのだけれど。 なんと--executeというオプションがついている。

これを使うことで再実行が可能になる。こういう感じ。

$ jupyter nbconvert --to notebook --execute Untitled.ipynb

すると、 Untitled.nbconvert.ipynb というファイルに再実行した結果が保存される。

--inplace を使えば上書き保存できる

--inplace を使えば上書き保存できる。こういう感じ。

$ jupyter nbconvert --to notebook --execute Untitled.ipynb

これは、Untitled.ipynbが更新される。

timeoutも伸ばしておいた方が良いかもしれない。

defaultでは30秒のtimeoutが設定されている。30秒以上掛かるような処理だった場合に中断されてしまうのは悲しい。そんなわけでtimeoutを伸ばしておくと良い。

$ jupyter nbconvert --ExecutePreprocessor.timeout=600 --to notebook --execute Untitled.ipynb

おまけ: 手で書いたipynbをレンダリングさせるということもできなくはない 

こういう雑なmatplotlibで図を描くコードがあるとする。

import matplotlib.pyplot as plt
plt.style.use("ggplot")

xs = list(range(10))
ys = [x * x for x in xs]
plt.plot(xs, ys, "g")

これを以下のようにipynbに手動で変換する(metadataなど埋めたほうが良い部分はあったりする)

{
  "cells": [
    {
      "cell_type": "code",
      "execution_count": 1,
      "metadata": {},
      "source": [
        "%matplotlib inline\n",
        "import matplotlib.pyplot as plt\n",
        "plt.style.use('ggplot')\n",
        "\n",
        "xs = list(range(10))\n",
        "ys = [x * x for x in xs]\n",
        "plt.plot(xs, ys, 'g')"
      ]
    }
  ],
  "metadata": {},
  "nbformat": 4,
  "nbformat_minor": 2
}

.ipynbはJSONファイルなので手書き出来ないことはない。例えばgraph.ipynbなどの名前で保存しておく。 その後この保存したファイルに対してnbconvert --execute を実行してグラフも含んだipynbを手に入れる事ができる。 (警告が出ているので本当はもう少し真面目にjson書いた方が良いかもしれない)

$ jupyter nbconvert --ExecutePreprocessor.timeout=600  --execute --to notebook 01graph.ipynb
[NbConvertApp] Converting notebook 01graph.ipynb to notebook
[NbConvertApp] ERROR | Notebook JSON is invalid: 'outputs' is a required property

Failed validating 'required' in code_cell:

On instance['cells'][0]:
{'cell_type': 'code',
 'execution_count': 1,
 'metadata': {},
 'source': 'import matplotlib.pyplot as plt\n'
           "plt.style.use('ggplot')\n"
           '\n'
           'xs = li...'}
[NbConvertApp] Executing notebook with kernel: python
[NbConvertApp] Writing 570 bytes to 01graph.nbconvert.ipynb

たくさん一気に同様の形式で別のファイルにグラフを描きたいときなどには便利かもしれない。

すると以下の様なグラフを生成した結果を含んだipynbが作れる。

graph

(例が二次関数のグラフなのは寂しいのでもうちょっと良い感じの絵を表示するようなものにしたい気持ちもあったりする)

gistはこちら

追記: ipynbの作成にはnbformatを使うと便利

ipynbの作成にはJSONを手書きするよりnbformatを使うと便利。

例えば以下の様な形で使う。

import textwrap
from nbformat.v4 import new_code_cell, new_notebook, writes_json

notebook = new_notebook()
sources = [
    """
    import random
    random.random()
    """,
    """
    import random
    random.random()
    """,
]

for i, source in enumerate(sources, 1):
    notebook["cells"].append(new_code_cell(textwrap.dedent(source), execution_count=i))

print(writes_json(notebook))

これを実行すると以下の様なipynbが生成される。便利。

{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": 1,
   "metadata": {},
   "outputs": [],
   "source": [
    "\n",
    "import random\n",
    "random.random()\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "metadata": {},
   "outputs": [],
   "source": [
    "\n",
    "import random\n",
    "random.random()\n"
   ]
  }
 ],
 "metadata": {},
 "nbformat": 4,
 "nbformat_minor": 2
}

グラフ描く簡単な環境作っときたいかも

グラフ描くの簡単な環境作っときたいかも。要求は以下の2つ。

  • 手軽にグラフが描ける
  • 手軽にグラフを共有できる

前者は例えばjupyter上でだけ表示みたいなのが嫌な感じ。 後者は例えばGUIで表示とかだけなのは嫌な感じ。

まだ結局一番良いと思える方法は見つかっていない(かなしい)。

今のところの方法

今のところとりあえずグラフの描画にはpyplotを使うという気持ちでいる。

とりあえず表示する場合と共有する場合とで分けていて。全部jupyterで済ませれるならそれ。 そうじゃない場合は以下の方法でやっている。

画像を表示するだけ

import matplotlib.pyplot as plt
plt.style.use('ggplot')

plt.plot([1, 2, 3, 4])
plt.ylabel('y')
plt.show()

line.png

画像を共有する場合

画像を出力してその画像を共有というのが無難な感じそう。

import matplotlib
matplotlib.use("AGG")  # NOQA
import matplotlib.pyplot as plt
plt.style.use('ggplot')

plt.plot([1, 2, 3, 4])
plt.ylabel('y')

dpi = float(plt.gcf().get_dpi())
plt.gcf().set_size_inches(400 / dpi, 300 / dpi)

plt.savefig("images/line-400x300.png", dpi=dpi)

dpiの指定に現在のfigureが持っているdpiを使うのと、rcParamsの中のsavefigのdpiを使うのとどちらが良いのかあんまり分かっていない。

$identify images/line-400x300.png
images/line-400x300.png PNG 400x300 400x300+0+0 8-bit sRGB 10.7KB 0.000u 0:00.000

ただ、gistにuploadするときにはちょっと大変な作業を踏まないといけないのであんまり画像で共有というのが機能しないような気もする。

問題点

問題点は以下の様な感じ。

  • グラフの表示と画像として出力とでコードが変わってしまう
  • 画像として出力してもgist上では楽に共有できない

画像として出力した結果を共有するなら画像サーバー的なものを用意してそこにuploadした後にmarkdown上で参照するという感じが良いのかも。 答えは見つかっていない。

jupyterでやれば良い場合

なんだかんだでgistで共有する場合にはjupyterでやるのが一番ラクな気もする。

import matplotlib.pyplot as plt

plt.style.use('ggplot')

plt.plot([1, 2, 3, 4])
plt.ylabel('some numbers')

以下をわすれずに

%matplotlib inline

こういう感じ

問題点

問題点はipynbのレンダリングが遅いこと数秒程度は普通に待たされる。 その後

Sorry, something went wrong. Reload?

とか悲しい感じ(githubやgist上での話)。