term タグ別の記事一覧

変数の参照を操作するJinjaテンプレートエンジン用カスタムタグを作った

 前回記事「Jinjaテンプレートエンジン用のエクステンション入門」ではJinjaテンプレートエンジン用エクステンションの作り方を紹介しましたが、実際にエクステンションを作ってみようとすると細かいところでよく分からないところが出てきたりしたので、今回は実際に作成したエクステンションの紹介と、前回記事についていくつか補足したいと思います。

作成したJinja用エクステンション

 今回作成したのは、{% importprop %}というカスタムタグを提供するエクステンションです。このカスタムタグは{% endimportprop %}タグと組み合わせて使用するもので、オブジェクト(一般的にはdict型のオブジェクト)を引数として取ります。

 {% importprop %}{% endimportprop %}で囲まれたブロック内では、引数で指定したオブジェクトの属性(もしくはdictに格納されている要素)に対してprefixなし、つまり「オブジェクト名.」という表記を省略した形でアクセスできるようになります。

 たとえば、次のテンプレートでは{% importprop %}{% endimportprop %}で囲まれたブロック内でbarという変数を参照していますが、実際にはfoo.barの値が参照されるようになります。

{% importprops foo %}Value of the `bar` property of `foo` is {{ bar }}.{% endimportprops %}

 もし対応する属性が存在しなかった場合は、そのブロック外で定義されていた値が代わりに使用されます(ブロック外でその値が定義されていなければundefinedになる)。

 JavaScriptではwithという構文がありますが(ドキュメント)、これはこのwith文と同じような働きをするものと考えると分かりやすいかもしれません(ただし、現代のJavaScriptにおいてはwithの使用は非推奨です)。

 このカスタムタグは、dict型の変数を格納したlistfor文でイテレートしてテンプレートをレンダリングしたいが、もしそのdict内で特定のキーとそれに対応する値がセットされていなかった場合は別のデフォルト値を代わりにレンダリングしたい、というケースでの利用を想定しています。

 たとえば次の例では、ブロック内でtypenameの2つの変数が使用されていますが、これらは実際にはitem.typeitem.nameを参照して値が決定され、かつitem.typeが定義されていなければその代わりにtype変数(ここでは"default"という文字列が格納されている)が参照されます。

{% set type = "default" %}
{% for item in items %}
{% importprops item %}
<li><span class="iten-name {{ type }}">{{ name }}</span>
{% endimportprops %}
{% endfor %}

 このExtensionはGitHubで公開しているので、詳しくはそちらもご参照ください。

実装のポイント

 前述のように、このカスタムタグはJavaScriptのwith文にインスパイアされたものです。Jinja2にはすでにwith文{% with %}タグ)が実装されてはいるのですが、これは変数名と代入する値を引数で指定する必要があるため、JavaScriptのwithのようにdictの要素すべてをまとめてprefix無しでアクセスしたいという用途には使えません。ただ、動作としては似ており、かつこのwithタグは以前はExtensionとして実装されていたので、当初はこれを少し修正すればすぐに実装できると考えたのですが、実際は次のような理由で簡単ではありませんでした。

  • Extensionはテンプレートのパース処理には介入できるが、テンプレートレンダリング時のコンテキストを直接操作することはできない
    • コンテキスト内の変数に対してなんらかの処理を行いたい場合は、Pythonコードで処理を行うのではなく、必要な処理をASTノードとして組み立てて、それをExtensionのparse()メソッドの戻り値として返す必要がある
  • コンテキストに値をセットする処理を行うASTノードは定義されているが、その変数名には定数しか与えられないようだ
    • つまり、コンテキスト内に格納されている値を変数名として使用することができなさそう
      • (もしかしたら可能なのかもしれないが見つけられなかった)

 よくよく考えてみると、JinjaではテンプレートをPythonコードに変換し、そのコードに変数とその値を引数として与えて実行することでレンダリングを行っており、かつPythonでは変数の値を参照して変数の名前を定義することは基本的にできません。そのため、Jinjaにおいてもコンテキスト内の値を参照して変数を定義することは難しいのではないか、と判断しました。

 ちなみに、当初はコンテキスト内に動的に変数を追加するような実装を想定して仕様を作成し、それをAIに渡して実装してもらおうとしたのですが、なにやら禅問答のようなものが始まり長考に入ってしまったためそちらは諦めています。テストコードについてはほぼ適切なものが出力されていたので仕様については理解できていたようなのですが、実装方法を見つけられなかったようでした(そもそもJinjaのExtensionの実装に関する知識が少なかったのかもしれません。もしかしたら高性能なAIを使えば実装できたのかもしれませんが……)。

 そもそもこのExtensionで実現したいことは変数を追加定義することではなく、prefixなしでの変数アクセスをprefix付きのアクセスのように扱わせたい、ということです。そこで、ブロック内の変数アクセスをすべて書き換える、という方針に転換して再度実装を考えることにしました。

ASTにおいて変数へのアクセスはどう表現されているのか

 JinjaのASTではStatement(テンプレート上で{% ... %}の形で表現されている部分)やExpression(テンプレート上で{{ ... }}の形で表現されている部分)といったテンプレートの構成要素に対応したクラスが用意されており、文字列として渡したテンプレートはパースされた後にこれらクラスのオブジェクトに変換されます。

 たとえば変数へのアクセスは、jinja2.nodes.Nameというクラスのオブジェクトに変換されます(ドキュメント)。このクラスは変数名を示すnameと、変数に対する操作を示すctxという2つのパラメータを持っています。たとえば、fooという変数から値を取り出すという操作に対応するノードは次のようになります。

jinja2.nodes.Name("foo", "load")

 また、fooオブジェクトのbar属性、つまりfoo.barから値を取り出す操作は、次のようにjinja2.nodes.Getattrクラスを使ったノードで表現されます。

jinja2.nodes.Getattr(jinja2.nodes.Name("foo", "load"), "bar", "load")

 つまり、barという変数へのアクセスを、fooオブジェクトのbar属性へのアクセスに置き換えるには、jinja2.nodes.Name("bar", "load")jinja2.nodes.Getattr(jinja2.nodes.Name("foo", "load"), "bar", "load")に置き換えれば良い、ということになります。また、属性が存在するかどうかはjinja2.nodes.Testクラスを使ったノードで表現できます。今回作成したExtensionでは、このような置換を行うために次のような関数を実装し、{% importprop %}{% endimportprop %}で囲まれたブロック内のjinja2.nodes.Name型のオブジェクトに対してこの関数を適用することで変数へのアクセスを書き換えています。

def replacer(node:Node):
    """Internal function to generate a node to get value if given name exists"""
    return nodes.CondExpr(
        nodes.Test(
            nodes.Getattr(arg_node, node.name, "load", lineno=node.lineno),
            "defined", [], {}, None, None),
        nodes.Getattr(arg_node, node.name, "load", lineno=node.lineno),
        node)

ASTの各クラスのコンストラクタはどのような引数を取るのか

 さて、このようにASTを操作する処理を実装する場合、ASTを構成する各クラス(jinja2.nodes.Nodeの派生クラス)がどのようなパラメータを取るのかを知る必要があります。しかし、ドキュメントにはそれぞれのクラスのコンストラクタがどのような引数を取るのかは明記されていません。たとえば、上記のjinja2.nodes.Testクラスについては次のように表記されています(ドキュメント)。

class jinja2.nodes.Test(node, name, args, kwargs, dyn_args, dyn_kwargs)
 
Apply a test to an expression. name is the name of the test, the other field are the same as Call.

 また、jinja2.nodes.Callクラスのドキュメントは次のようになっています、

 class jinja2.nodes.Call(node, args, kwargs, dyn_args, dyn_kwargs)

Calls an expression. args is a list of arguments, kwargs a list of keyword arguments (list of Keyword nodes), and dyn_args and dyn_kwargs has to be either None or a node that is used as node for dynamic positional (*args) or keyword (**kwargs) arguments.

 ここから、kwargs以下の引数にはそれぞれNodejinja2.nodes.Node)派生クラスのリストを、name引数は実行するテストの名前を与えれば良さそうで、またnode引数はその名前からNodeクラスのオブジェクトを取りそうな雰囲気はありますが、具体的にどのような値を与えれば良いのかは、これだけではまったく分かりません。ただ、幸いなことにJinjaのソースコードには現代のPythonコードらしく型注釈が付与されており、それを確認することでどのような引数を与えれば良いかを確認できます。

 たとえばjinja2.nodes.Testクラスは次のように実装されています(ソースコード)。

class Test(_FilterTestCommon):
    """Apply a test to an expression. ``name`` is the name of the test,
    the other field are the same as :class:`Call`.

    .. versionchanged:: 3.0
        ``as_const`` shares the same logic for filters and tests. Tests
        check for volatile, async, and ``@pass_context`` etc.
        decorators.
    """

    _is_filter = False

 ここだけを見てもよく分からないのですが、派生元の_FilterTestCommonクラスは次のようになっています(ソースコード)。

class _FilterTestCommon(Expr):
    fields = ("node", "name", "args", "kwargs", "dyn_args", "dyn_kwargs")
    node: Expr
    name: str
    args: t.List[Expr]
    kwargs: t.List[Pair]
    dyn_args: t.Optional[Expr]
    dyn_kwargs: t.Optional[Expr]
    abstract = True
    _is_filter = True

 jinja2.nodes.Nodeクラスの派生クラスはやや特殊な実装になっており、fieldsクラス変数にコンストラクタが受け取る引数の名前が格納されています。そして、各引数の型はその下の型定義で確認できます。この例の場合、次のようになります。

  • node: jinja2.nodes.Exprの派生クラス
  • name: str(文字列)
  • args: jinja2.nodes.Expr`派生クラスを格納したリスト
  • kwargs: jinja2.nodes.Pair`派生クラスを格納したリスト
  • dyn_args: jinja2.nodes.Exprの派生クラス(オプショナル)
  • dyn_kwargs: jinja2.nodes.Expr`の派生クラス(オプショナル)

 これで、少なくともそれぞれの引数にどのような型の値を与えれば良いのかについては判別できます。

 ちなみに、この型宣言だけでは分からないのですが、name引数にはJinjaドキュメントの「List of Builtin Tests」に記載されているテスト名のいずれかを指定します。たとえば、{% if variable is defined %}に相当するノードではdefinedを指定します。また、argskwargsには各テストに与えるvalue以外の引数を指定します。definedは引数としてvalueのみを取るため不要ですが、たとえばsameasはのように追加の引数を取るので、この追加の引数をargs引数で与える形になります。

{% if foo.attribute is sameas false %}

 このように、ドキュメントとソースコードを一緒に確認しつつASTを組み立てたり操作しなければいけない点が、JinjaのExtension実装で大変なところでしょう。

 なお、jinja2.nodes.Node派生クラスのオブジェクトは、コンストラクタでパラメータを指定せず(つまりコンストラクタでは引数を与えない)、インスタンスを作成したあとに引数のフィールド名と同じ属性に値を代入することでも操作できます。たとえば、some_node = jinja2.nodes.Name("foo", "load")というコードは、次のように書くことができます。

some_node = jinja2.nodes.Name()
some_node.name = "foo"
some_node.ctx = "load"

 補完機能を持つエディタでコードを書いている場合、コンストラクタに引数を与える形だとコード補完や型チェックが動作しない可能性があるため、後者の書き方のほうが良いのかもしれません。

Jinjaテンプレートエンジン用のエクステンション入門

 Pythonで利用できるテンプレートエンジンの1つにJinjaというものがあります。一般的にはWebページのレンダリング等で使われることが多いですが、近年ではAI系ツールでもよく使われている、とても多機能なテンプレートエンジンです。

 このJinjaには、独自のテンプレートタグを追加できる「Extensions」という機能があります。このExtensionsを利用することで、Jinjaにおける標準的なテンプレートのルールにとらわれない、柔軟なテンプレート処理が実現できます。

 しかし、このExtensions機能はあまり使われていないようで、ネットを検索してもあまり情報がありません。そもそも公式ドキュメントではExtensionsを実装するためのAPIについての記載はあるものの、最低限のサンプルコードしかなく、チュートリアル的なものもほとんどありません。実際のところ、JinjaはExtensionsを使わなくてもテンプレート内から簡単にPythonの関数を呼び出すことができるため、Extensionsをわざわざ実装しなくてもほとんどのユースケースに対応できます。そのためExtensionsを実装する需要は少なく、結果として利用例が少ないのだと思われます。

 いっぽうで、ほかのテンプレートエンジンでは利用できるがJinjaでは利用できないような機能を使用したい場合、このExtensions機能を使って実装することでその機能をJinjaに追加することができます。これは、他のテンプレートエンジンでの利用を想定して作成されていたテンプレートをJinja向けに移植する際などに有用です。自分もそういった目的でJinjaのExtensionsを作ってみようとしたのですが、AIにコーディングさせようとしたところ、あまりにも情報が少なすぎるのか、まともに動作するコードを生成できませんでした。ということで、ここでJinja向けのExtensionsについての簡単な解説を記しておきます。

 なお、本記事ではJinjaの基本的な使い方や用語について理解していることを前提としています。Environmentやテンプレートの文法、テンプレートにおけるStatementExpressionなどについての説明は、別途Jinjaのドキュメント等をご参照ください。

前提情報:Jinjaの仕組みとExtensions

 Jinjaでは、テンプレートをまずAST(Abstract Syntax Tree)と呼ばれる形式の表現に変換します。ASTはHTMLにおけるDOMのようなもので、テンプレートに記載されているコンテンツをツリー構造のオブジェクトに変換したものです。テンプレートは「Parser」と呼ばれるものでASTに変換され、続いて「Compiler」と呼ばれるものでASTがPythonの実行可能コードに変換されます。ここで得られた実行可能コードにレンダリングするパラメータを与えて実行することで、最終的な出力が得られます。レンダリングするパラメータ(変数)はコンテキスト(Context)と呼ばれるもので管理されており、レンダリング時に変数とその値をContextに渡せるほか、テンプレート内からContextに変数や値を追加したり、変数の値を変更したりすることができます。

Jinjaでのレンダリングの仕組み

 Extensionsでは、このテンプレートの処理プロセスに介入する仕組みが提供されています(ドキュメント)。Extensionはjinja2.ext.Extensionクラスを継承して実装するようになっており、JinjaのEnvironmentに対し実装したExtension派生クラスを登録することで、そのExtensionsが利用できるようになります。

 たとえばSomeExtensionというExtensionを実装した場合、次のようにするとそのExtensionが利用できるようになります。

import jinja2
from jinja2 import Environment
from some_extension import SomeExtension

env = Environment(extensions=[SomeExtension])

# 下記のような書き方でもOK
# env = Environment()
# env.add_extension(SomeExtension)

 EnvironmentにExtensionを登録すると、そのEnvironmentが引数として与えられてExtensionのコンストラクタが呼び出されます。また、以下のタイミングでExtensionの次のメソッドが呼び出されるようになります。

  • テンプレートの読み込み時:preprocess()メソッド
  • テンプレートのパース時:filter_stream()メソッド
  • Parserが指定したステートメント(カスタムタグ)を読み込んだとき:parse()メソッド

 これによって下記のようなことが実現できます。

  • Extensionの登録時にEnvironmentに対してパラメータの変更・追加やメソッドの追加といった操作を行う
  • Jinjaがテンプレートを読み込む際にそのテンプレートに対してなんらかの前処理を実行する
  • パースされたテンプレートを操作する
  • テンプレート内で独自のステートメント({% ... %})を利用できるようにする

parse()メソッドの実装

 上記の4つのうち、最初の3つ(コンストラクタ、preprocess()filter_stream())ではそれぞれのメソッドに対して引数として渡されたEnvironment変数やパース前のテンプレートのソース(文字列)、パースされてトークンに分割されたテンプレートなどを操作する処理を記述できます。それぞれのメソッドの戻り値についても、preprocess()はテンプレートの文字列、filter_stream()はトークンを返すイテレータを返せば良いため、メソッドの実装は「渡された入力に対し何らかのメソッドを実行して操作を行ってそれを返す」という、直観的で分かりやすい内容になります。

 一方、カスタムタグを実装するために使用するparse()メソッドでは引数としてテンプレートの内容を読み出すためのParserオブジェクトが渡され、これを使ってテンプレートの中身をパースしてAST(Nodeオブジェクト)を生成したり、必要に応じて独自にNodeオブジェクトを作成したりして、最終的にそれらを戻り値として返すという、やや分かりにくい処理を実装することになります。下記ではこの実装に必要となるASTおよびParserについてまず解説します。

Parserの内部処理

 前述のように、Parserドキュメント)はテンプレートをASTに変換する処理を担当するクラスです。ここでは次のような手順でテンプレートをASTに変換していきます。

Parserの内部処理1:テンプレートのトークン化(tokenize)

 Parserはまず渡されたテンプレート(str形式)をTokenと呼ばれる単位に分割します。

 たとえば、<a href="{{ url }}">{{ link_text }}</a>というテンプレートが渡された場合、Parserはこれを次のように9つのトークンに分割します。

テンプレートのトークン化

 このようなテンプレートからトークンへの変換処理は、Lexerというクラスで実装されています(実装コード)。Token自体を表現するTokenクラス(ドキュメント)やそのタイプなども同じジュールで定義されています。

 変換されたトークンはTokenStreamドキュメント)というイテレータブルなオブジェクトに格納されます。Parserオブジェクトからは、そのstreamプロパティでTokenStreamにアクセスできます。

Parserの内部処理2:トークンのパース

 続いてParserはこのTokenStreamからトークンを順に取り出して、ASTのオブジェクト(Nodeクラスを継承したクラスのオブジェクト)に変換していきます(実装コード)。たとえばtypedataのトークンが取り出されたら、その値を格納したTemplateDataクラスのオブジェクトを生成します。また、typevariable_beginのトークンが取り出されたら、Parserクラスのparse_tupleというメソッド(実装)を実行し、そこでtypevariable_endのトークンに到達するまでのトークンを処理します。typenameのトークンが取り出されたら、Nameクラスのオブジェクトを生成します。

トークンのAST化

 また、ASTのオブジェクトにはノードのコンテンツを出力するOutputクラスや、Pythonの関数やメソッドを実行するCallクラスといった、なんらかの処理を実行するためのものも用意されています。上記の例では、生成されたオブジェクトを最後にOutputクラスのオブジェクトに格納することで、テンプレートの中身が出力されます。

 なお、Parserクラスにはparse_*という名称でさまざまなタイプのトークンをパースしてASTに変換するためのメソッドが実装されており、Parserは必要に応じてそれらを使ってさまざまなオブジェクトを生成します。また、前述のようにASTを生成するまでがParserの役割で、そこから出力を実際に生成するための処理はCodeGeneratorという別のクラス(実装コード)で実装されています。Extensionはこの部分の処理には介入できないため、本稿ではここから先の処理についての詳細は割愛しますが、CodeGeneratorではASTを順に走査し、その内容に応じたPythonコードを出力する、といった処理を実行しています。ここで出力されたPythonコードはPythonの中間コードに変換され、Jinjaのテンプレートレンダリングメソッドが呼び出されるたびにそのコードが実行されてテンプレートがレンダリングされます。

Extensionのparser()メソッドが呼び出されるタイミング

 Parserはトークンのパース時にステートメント開始トークン(typelinestatement_beginのトークン)に遭遇した場合、parse_statement()というメソッドを実行してそれをパースしようとします(実装)。ここでステートメントの値({%%}で囲まれた部分の最初のキーワード)がJinjaの組み込みステートメントと一致した場合、それをパースするためのメソッドが実行されます。一致しなかった場合は、Environmentに登録されているExtensionのtagsプロパティを順にチェックし、ステートメントの値と一致するキーワードを持つExtensionが見つかれば、Parserオブジェクトを引数として与えてそのExtensionのparse()メソッドを実行します。

 Extensionのparse()メソッドが実行されたタイミングでは、与えられたParserオブジェクトのstreamプロパティ(Tokenを返すイテレータ)はそのステートメントの値を格納するName型のトークンを指した状態になっています。Extensionではこのstreamプロパティ、もしくはParserオブジェクトのメソッドを使ってTokenを走査し、ASTのノードを生成してそれらを戻り値として返す処理を実装することになります。

ASTの生成

 ASTのノードはjinja2.nodesモジュールのクラスとして実装されており、一般的なクラスと同じように生成できます。たとえば、テンプレート内の定数文字列を表すTemplateDataノードは次のように作成できます。

from jinja2 import nodes

templateDataNode = nodes.TemplateData(data)

 なお、ノードによって生成時に与えられる引数は異なりますが、lineno=およびenvironment=以外のキーワード引数を与えることはできません。多くのNodes派生クラスは独自のコンストラクタ(__init__()メソッド)を持たず、Nodesクラスのコンストラクタを使用する形で実装されており、そこでこれら以外のキーワード引数が与えられた際はTypeError例外を生成するように実装されているためです(実装)。つまり、次のような記述はできません。

templateDataNode = nodes.TemplateData(data=data)

Extensionの実装例

 以上をまとめると、Jinjaで{% some_statement %}という独自タグ(独自のステートメント)を実装するには、次のような手順を踏めば良いということになります。

  1. jinja2.ext.Extensionクラスを継承したSomeExtensionクラスを定義する
  2. SomeExtensionクラスのtagsプロパティに{ "some_statement" }をセットする
  3. SomeExtensionクラスのparse()メソッドに処理を記述する

 これを踏まえて、以下ではparse()メソッドに実装する処理の実例をいくつか紹介していきます。

固定文字列を出力する

 まずはシンプルに「hello world」という固定文字列を出力する{% helloworld %}タグを実装してみましょう。この独自タグを追加するHelloWorldExtensionクラスは次のようになります。

class HelloWorldExtension(Extension):
    tags = {"helloworld"}

    def parse(self, parser):
        token  = next(parser.stream)
        lineno = token.lineno
        return nodes.Output([nodes.Const("hello world")], lineno=lineno)

 前述のように、parse()メソッドが呼び出された時点では引数として渡されたParserクラスのオブジェクト(parser)のstreamプロパティはステートメント名を格納するTokenを指しているので、next()関数でまずこのトークンを取り出しています。Token型オブジェクトのlinenoプロパティにはそのトークンがテンプレート中の何行目に記述されているかを示す数値が入っており、Nodeクラス派生オブジェクトのコンストラクタにこれを与えることで、エラー発生時にこの行番号を表示してくれるようになります。これはテンプレートのデバッグ時に役立ちます。

引数として与えた値を出力する

 次の例は、ステートメントに続く値を引数として解釈し、それを出力するタグを実装したものです。たとえば{% hello "world" %}をレンダリングするとhello, worldという文字列が得られます。

 ここでは、Parserが持つstreamイテレータが指しているトークンを取り出し、それに対応するNodeを生成するparse_expression()メソッド(ドキュメント)を使って引数部分に対応するノードを生成しています。このメソッドはトークンが定数の場合はConstノードを返します。また、トークンが定数ではない文字列(たとえばworldのような"等で囲まれていない文字列)の場合はそれを変数名と解釈し、コンテキストから対応する値を取り出すNameノードを生成します。つまり、この{% hello %}タグでは定数と変数の両方を引数として与えることができます。

class HelloExtension(Extension):
    tags = {"hello"}

    def parse(self, parser):
        token  = next(parser.stream)
        lineno = token.lineno
        next_token = parser.stream.look()
        argNode = parser.parse_expression()
        return nodes.Output([nodes.Const("hello, "), argNode], lineno=lineno)

Extensionのメソッドを呼び出す

 レンダリング時にExtentionのメソッドを呼び出すことも可能です。次の例は、Extensionの_get_now()というメソッドを呼び出すものです。たとえば{% now "%y-%m-%d %H:%M:%S" %}というテンプレートをレンダリングすると、現在時刻を示す26-07-27 00:19:25のような文字列が出力されます。また、引数を与えなかった場合は、ISO形式の文字列で現在時刻が出力されます。

 メソッドを呼び出すにはExtentionsクラスのcall_method()メソッドを利用します(ドキュメント)。注意したいのが、このメソッドは指定したメソッドをその場で実行するのではなく、レンダリング時に指定したメソッドを実行するCallノードを生成して返すという点です。

 call_method()は引数としてメソッドの実行時に与える引数を格納したリストを受け取るのですが、このリストにはNode派生クラスのオブジェクトをそのまま格納できます。つまり、parse_expression()メソッドでパースしたASTをそのまま引数として渡せます。指定したメソッドが実行される際には、ASTのノードは適切な値に変換されてから引数として渡されます。また、メソッドの戻り値も適切なASTのノードに自動変換されます。

 {% now %}タグに変数が渡されているかどうかは、Parserクラスのstreamプロパティが指しているトークンの種類で判別できます。このトークンがblock_endであれば引数は存在しないので、引数を与えずに_get_now()メソッドを実行します。

class NowExtension(Extension):
    tags = {"now"}

    def parse(self, parser):
        token  = next(parser.stream)
        lineno = token.lineno
        
        # parser.streamが指しているトークンがblock_endなら引数は存在しない
        if parser.stream.current.type == "block_end":
            # 引数がないのでメソッド呼び出し時にも引数を与えない
            arg = []
        else:
            arg = [parser.parse_expression()]

        node = self.call_method("_get_now", arg)
        return nodes.Output([node], lineno=lineno)
        
    def _get_now(self, fmt=""):
        if fmt:
            return datetime.now().strftime(fmt)
        else:
            return datetime.now().isoformat()

終了タグを使ったカスタムタグ

 Jinjaでは、{% if ... %}{% endif %}のような、endで始まる終了タグと組み合わせて使用するステートメントがあります。Extensionsでもこのような終了タグと組み合わせて使用するカスタムタグを実装できます。次の例は、{% wrap %}タグと{% endwrap %}タグに囲まれた部分を<div></div>で囲んで出力するExtensionを実装したものです。

 このように終了タグを使用する場合、tagsプロパティにendwrapを追加するのではなく、Parserクラスのparse_statements()メソッド(ドキュメント)を使用するのがポイントです。このメソッドは、第1引数で指定した条件に合致するトークンまでを取得し、それをAST(Node派生クラスの配列)に変換したものを返します。なお、drop_needle引数は終了タグ部分のトークンを破棄するかどうかを指定する引数です。終了タグに対して特に追加処理を行わないのであれば、Trueを指定します。

class WrapExtension(Extension):
    tags = {"wrap"}

    def parse(self, parser):
        token  = next(parser.stream)
        lineno = token.lineno
        body = parser.parse_statements(["name:endwrap"], drop_needle=True)
        
        # 先頭と末尾にDIV開始タグと終了タグを出力するノードを追加する
        body.insert(0, nodes.Output([nodes.Const("<div>")]))
        body.append(nodes.Output([nodes.Const("</div>")]))
        
        return body

まとめ

 ここでは基本的なExtensionの実装例のみを紹介しましたが、Extensionのparse()メソッドではテンプレートのトークンに直接アクセスできるため、他のタグ(ステートメント)に相当するトークンがパースされる前にそれを乗っ取って挙動を変えたり、本来のJinjaテンプレートではエラーになるような文法を実装する、といったことも可能になります。いっぽうで、あくまでトークンをASTに変換するといった処理しかできないため、コンテキストの値に応じて動的に内容が変わるような処理を実装したい場合は工夫が必要です。また、単にメソッドを実行してその結果をレンダリングしたいのであれば、コンテキストにメソッドを追加して実行させるほうが簡単な場合もあります。実現したい処理に応じて、ケースバイケースで利用するのが良さそうです。

PDFがCMYKで出力されているのを無課金で調べる

 私事で印刷業者にちょっとしたものを印刷してもらう用事ができたのですが、その入稿データとしてCMYKで出力されたPDFを要求されました。CMYKはシアン、マゼンタ、イエロー、黒の4色を混ぜ合わせてカラー原稿を表現する方式で、印刷業界で標準的に使われている方式です。いっぽう、PCでは一般的にRGB(レッド、グリーン、ブルー)の3色を混ぜ合わせて色を表現するのが一般的で、多くの画像編集ツールやデジタルカメラなどではこちらのRGBを使った形式でファイルを出力することが多いです。そのため、CMYKを取り扱うにはCMYK出力をサポートしたソフトウェアが必要になります。

 といっても、CMYKでPDFを出力すること自体はInkscape等のフリーソフトウェアでも可能です。とはいえ、特にCMYKでの出力は初めてということもあって、そこで出力したものが本当に正しくCMYKで出力されているのか、入稿前に確認しようと考えました。

 軽く調べたところ、Acrobatの有償版(Acrobat Pro)で利用できる「印刷プレビュー」機能を利用すれば、使用されている色やRGBデータの有無を確認できるようです。ただ、単にデータを確認するだけに安くない金額を払うのにはとても抵抗感があります。また、他にも印刷向けにPDFの情報をチェックできるソフトウェアが存在するようですが、見つけたものはいずれも有償のものでした。

 ということで、連休で時間があるというのもあってPDFフォーマットを分析して自力でPDFでCMYKが使用されているかを調べる方法を調べることにしました。

参考資料

 PDFのフォーマット仕様は標準規格として公開されており、その仕様書はAdobeのオープンソースサイト(https://opensource.adobe.com/dc-acrobat-sdk-docs/acrobatsdk/)で公開されています。ただし英語で分量も多いため、これをいきなり読むのはハードでしょう。

 日本語の情報としては、下記が有用でした。

PDFがCMYKで出力されているかを調べるために必要な情報の要約

 以上の資料を読んだうえで、後述するPDFを取り扱うライブラリを使っていくつかのPDFを確認したところ、以下のような理解になりました。

  • PDFはオブジェクトと呼ばれるデータ要素の組み合わせで構成されている
  • PDFには必ずルートオブジェクトがあり、ページ内のコンテンツはルートオブジェクトからたどれる/Pagesオブジェクトに入っている
    • ルートオブジェクトにはそのほか画面表示のためのプロパティやメタデータなどが含まれていることがあるが、これらはPDFが含む色には関係ない
      • PDF作成ツールによってはメタデータに使用している色空間の情報が入っていることもあるようだ
  • PDFには「Indirect Object」という別のオブジェクトを参照する仕組みがあり、各オブジェクトの子オブジェクトとして実際のオブジェクトを指定する代わりにIndirect Objectを使って別のオブジェクトを参照させることもできる
  • /Pagesオブジェクトはページを表現する/Pageオブジェクトを子オブジェクトとして保持する/Kidsオブジェクトを含んでいる
  • /Pagesオブジェクトや/Pageオブジェクトには各ページで使われているコンテンツや各種設定を含むリソースやページサイズ、印刷サイズといった情報が含まれている
  • PDFは1ファイル中にさまざまな色空間を混在させることができる
    • 色空間としてはグレイスケール、RGB、CMYKを取り扱える。さらにそれぞれカラーマネージメント(デバイスや出力装置に応じて適切に補正を行うことで色の再現性を高める仕組み)あり/なしの両方をサポートする
  • ページ内に描画するコンテンツは各/Pageオブジェクト内の/Contentsオブジェクトに格納されている
  • PDFはテキストベースで記述されているが、/Contentsオブジェクトにはテキストを圧縮したバイナリデータが含まれていることがある。その場合、/Contentsオブジェクト内のデータにアクセスするには指定された方式でバイナリを展開する必要がある
  • /Contentsオブジェクトには「<オペランド(パラメータ)> <オペレータ(命令)>」という形式で描画命令が格納されている。用意されている命令には直線や図形、文字などを描画するものや領域を塗るといったものに加えて、色や描画パラメータを変更するものや、描画するオブジェクトをグルーピング(レイヤー分け)するものなどが含まれている
    • 色や塗り潰しパラメータはグレイスケール/RGB/CMYKで指定できるほか、あらかじめ定義しておいた色(インデックスカラーもしくは特色)やパターンを選択することもできる
    • 色や塗り潰し、描画のパラメータは直接記述することもできるし、/Pagesや/Pageオブジェクト内の/Resourcesオブジェクトに格納しておいてそれを参照することもできる
      • あらかじめ定義しておいた色やパターンは/ColorSpaceというキー、また描画パラメータについては/ExtGStateというキーにひも付けられて格納されている

 まとめと言いながら長々と書いてしまいましたが、まず重要なポイントとしてはPDF内では複数の色空間を同時に扱えるという点です。そのため、「CMYKで出力されている」かどうかは、「グレイスケールもしくはRGBが使われていない」で判断することになりそうです。

 また、インデックスカラーや特色、塗りつぶしパターンは/Resourceオブジェクトに格納されていることもありますが、グレイスケール/RGB/CMYKの色指定は/Contentsオブジェクト内に記述された命令で行われています。そのため、グレイスケール/RGB/CMYKのどれが使われているかは/Contentsオブジェクト内で使われている色指定命令をすべて調べる必要があるようです。

pypdfでPDFの内部データにアクセスする

 PDFではIndirect Objectを使った参照が頻繁に使われるほか、バイナリデータを保持する/Contentsオブジェクトなども存在するため、内部構造を解析するだけでも大変です。そのため、今回はPython向けのPDFライブラリであるpypdfを利用して内部データにアクセスすることにしました。

 pypdfでは、次のようにpypdf.PdfReaderクラスを使用することでファイルオブジェクトなどからPDFデータを読みだすことができます。

with open(fn, "rb") as fp:
    r = pypdf.PdfReader(fp)

 ルートオブジェクトは、pypdf.PdfReaderオブジェクトのroot_objectプロパティに格納されています。たとえば次のようなコードでルートオブジェクトに格納されているオブジェクトとそのクラスを確認できます。

print("ROOT OBJECTS:")
for k in r.root_object:
    print(k, r.root_object[k].__class__)

 たとえば、とあるPDFのルートオブジェクトを確認した結果、次のような出力になりました。

ROOT OBJECTS:
/Type <class 'pypdf.generic._base.NameObject'>
/Pages <class 'pypdf.generic._data_structures.DictionaryObject'>
/ViewerPreferences <class 'pypdf.generic._data_structures.DictionaryObject'>
/Lang <class 'pypdf.generic._base.TextStringObject'>
/OCProperties <class 'pypdf.generic._data_structures.DictionaryObject'>
/Metadata <class 'pypdf.generic._data_structures.DecodedStreamObject'>

 pypdfではPDFで使われる各オブジェクトの形式に応じたクラスが定義されており、PDF内のオブジェクトは自動的に対応するpypdfのオブジェクトに変換されて木構造が構築されます。

ページ内のコンテンツを確認する

 前述のように、PDFの各ページはルートオブジェクトから参照される/Pagesオブジェクトに格納されています。このオブジェクトの情報は、次のようなコードで確認できます。

print("PAGES:")
print(p.root_object["/Pages"])

 実行結果は下記のようになりました。これは、PDFのページ数が1で、また/Pagesオブジェクトにはその実態となるオブジェクトの代わりにそのオブジェクトへの参照(IndirectObject)が含まれていることを意味します。

{'/Type': '/Pages', '/Count': 1, '/Kids': [IndirectObject(7, 0, 1219641424528)]}

 pypdfでは、get_object()メソッドでそのIndirect Objectが参照するオブジェクトの実態を取得できます。つまり、この例の場合、PDF内に含まれる唯一のページに対応するオブジェクトは次のようにして取得できます。

page = pages["/Kids"][0].get_object()

 /Pageオブジェクトには、/Contentsオブジェクトとしてそのページ内に描画されるデータが含まれています。また、get_data()メソッドでオブジェクトからその生バイナリデータを取得できます。

raw_content = page["/Contents"].get_data()

 PDFの描画データ(描画命令)はテキスト形式で表現できますが、/Contentsオブジェクトに含まれているデータはその生データ(バイナリデータ)なので、テキストとして扱いたい場合はデコードする必要があります。たとえばデコードしたデータを出力するには次のようにします。

print(raw_content.decode("ascii"))

 次の例は、このコードで実際に抜き出した描画データの一部です。

/OC/oc2 BDC
0.721569 0.678431 0.670588 0.882353 k
5.58236 48.749 m
8.19161 48.749 l
8.57899 48.749 8.86249 48.6587 9.04211 48.4782 c
9.22174 48.2979 9.31155 48.0342 9.31155 47.6872 c
9.31155 45.7844 l
9.31155 45.5798 9.27192 45.4135 9.19267 45.2857 c
9.11355 45.1579 8.99586 45.0742 8.83961 45.0346 c
8.99586 44.9937 9.11355 44.9182 9.19267 44.8081 c
9.27192 44.698 9.31155 44.5414 9.31155 44.3384 c
9.31155 42.2962 l
9.31155 41.9492 9.22174 41.6854 9.04211 41.5049 c
8.86249 41.3245 8.57899 41.2344 8.19161 41.2344 c
5.58236 41.2344 l
h

 最初の「/OC/oc2 BDC」はこれから描画するコンテンツがどのレイヤーに所属しているかを示すものです。「/oc2」は/Pageオブジェクトの/Resources内で定義されているプロパティで、このプロパティにはレイヤー名やそのレイヤーに関する情報が含まれています。

 次に「0.721569 0.678431 0.670588 0.882353 k」という命令が続きます。この「k」は次のような形でCMYK形式で色を指定する命令です。

<c> <m> <y> <k> k

つまり、これに続く命令はCMYKで指定された色で描画を行うことになります。

なお、CMYK形式の場合は「k」もしくは「K」ですが、グレイスケールの場合は「g」もしくは「G」、RGBの場合は「rg」もしくは「RG」で色を指定します。大文字の場合ストロークの色を、小文字の場合はストローク以外の塗りつぶし等の色を指定することになります。

 色指定にはそのほか「sc」「SC」や「scn」「SCN」という命令もありますが、これらは色空間を指定する「cs」「CS」命令と組み合わせて使うもので、csもしくはCS命令で指定した色空間に応じてパラメータを指定することになります。

描画に使用されている色空間を判断する

 さて、本記事のテーマである色空間の判別ですが、まとめると以下のようになります。

  • 描画データ中に「g」や「G」命令がある場合:グレイスケールが使われている
  • 描画データ中に「rg」や「RG」命令がある場合:RGBが使われている
  • 描画データ中に「k」や「K」命令がある場合:CMYKが使われている
  • 描画データ中に「sc」や「SC」、「scn」、「SCN」命令がある場合:その時点で最後に実行された「cs」「CS」で指定されている色空間が使われている

 この中で厄介なのが「sc」や「SC」、「scn」、「SCN」命令のパターンで、この場合まずその前に実行されている「cs」や「CS」命令を探し、そこで指定されている色空間がグレイスケール/RGB/CMYKのどれなのかを調べる必要があります。「cs」や「CS」命令ではパターンや特色を指定することもでき、その場合そのパターンや特色がどの色空間で指定されているかも調べる必要があります。

 これを踏まえて問題のPDFのデータを確認したところ、描画中には「k」と「K」命令しか含まれていませんでした。つまり、このPDFは無事CMYKで出力されていた、と言うことになります。

 ちなみに確認は上記で挙げたコード例のようなコードでページのコンテンツをテキストファイルに出力し、それをテキストエディタで開いて「g」や「rg」、「k」、「sc」という文字列を検索して目視で確認するという原始的な方法で行いました。今回は1ページだけのPDFファイルで、かつラスター画像を含まないものだったのでこれで十分でしたが、ページ数が増えた場合はプログラムを書いてちゃんと分析しないと大変そうです。

「Pythonではバイナリファイルを操作したい時にバイナリ列をリストに変換して操作した後にまたバイナリ列に戻さなければならない」というのは不正確

 「Pythonでバイナリファイルを操作したい場合にファイルから読み出したバイナリ列をリストに変換しないとデータを自由に編集できないし、書き出し時に再度バイナリ列に変換しなければならないのでPythonは嫌い」という話を見かけたのですが、それは不正確だし適切な処理ではないです。

 そこではこの処理について、次のように記述されていました(適当に抜粋・改変しています)。

f = open("somefile.bin", "rb")
bin = f.read()
f.close()

data = []
for b in bin:
    data.append(b)

# ここでインデックスを使ったdata配列の操作を行う
# :
# :

f = open("output.bin", "wb")
f.write(bytearray(data))
f.close()

 このコードでは、データをbinという変数に読み込んだ後、dataというlistに入れて処理し、最後にそのlistをbytearray型に変換してからファイルに書き出しています。確かにこう書いてしまうと、配列への変換が冗長です。そもそもなぜこういった処理が必要になるかというと、open()でバイナリ形式での読み出し(“rb"フラグ)を指定した場合、読み出されたデータはbytes型のオブジェクトとして返されます。

$ python3
Python 3.7.5 (v3.7.5:5c02a39a0b, Oct 14 2019, 18:49:57) 
[Clang 6.0 (clang-600.0.57)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>
>>> with open('testdata.bin', 'rb') as f:
...     bin_data = f.read()
... 
>>>
>>> type(bin_data)
 <class 'bytes'>

 bytes型では、インデックスを使ってその値を取得することができます。

>>> bin_data[48]
48

 一方で、bytes型はimmutableなオブジェクトなので、値を変更することはできません。

>>> bin_data[48] = 'A'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'bytes' object does not support item assignment

 しかし、実はPythonにはbytearrayというバイト列を扱うmutableな型もありまして、これを使えばbytes型と同様にインデックスを使った指定でデータを書き換えることができます。

>>> ba = bytearray(bin_data)
>>>
>>> type(ba)
 <class 'bytearray'>
>>>
>>> ba[48]
48
>>>
>>> ba[48] = 0x10
>>>
>>> ba[48]
16

 さらにこのbytearray型のオブジェクトは、write()メソッドの引数として指定することで直接ファイルに書き出せます。

>>> with open('output.bin', 'wb') as f:
...     f.write(ba)
... 
256

 ということで、先に挙げられていた「不満のあるコード」は次のように書き換えられます。

with open("somefile.bin", "rb") as f:
    data = bytearray(f.read())

# ここでインデックスを使ったdata配列の操作を行う
# :
# :

with open("output.bin", "wb") as f:
    f.write(data)

 このコードでも、ファイルから直接bytearray型のオブジェクトに読み込ませている訳ではない(bytes型オブジェクトを中継している)ため、結局バイナリ列をリストに変換しているじゃないか、と言われたらまあそうなんですが、それをバイナリ列に戻さなくても書き込みはできます、という話でした。

mod_wsgiでeasy_installとかでインストールしたPythonモジュールを読み込めない場合の対処

 WSGIを使ってWebアプリケーションを実装して、Apache+mod_wsgi環境で実行しようとした場合に、特定のモジュールがインポートできないという問題が発生することがある。具体的には、SELinuxが有効な状態で、easy_installなどでインストールしたPythonモジュールをインポートできないというものだ。この場合、以下のようにしてPythonのsite-packagesディレクトリのラベルを修正することで対応できる。

# restorecon -FRv /usr/lib/python2.7/site-packages/

Python 2.6/2.7のxmlrpclibでxml.parsers.expat.ExpatErrorが出た場合の対処

 Python 2.6系および2/7系のxmlrpclibで、サーバーからはどう見ても正しいXMLが返ってきているはずなのにxml.parsers.expat.ExpatErrorが出た場合の対処方法メモ。

 xmlrpclibでは、サーバーから受け取ったXMLデータを一定サイズごとに分割してXMLパーサーに投入する、という処理を行っている。このときXMLパーサーにExpatを使っていると、受け取ったデータが分割される位置によっては、不正なXMLと認識されて以下のようにエラーになる模様(DebianのPython 2.6.6とMac OS XのPython 2.7.2で確認)。Expatを使わないようにするとこの問題は発生しない。

  File "/Users/hylom/repos/anpanel/myxmlrpclib.py", line 559, in feed
    self._parser.Parse(data, 0)
xml.parsers.expat.ExpatError: not well-formed (invalid token): line 30, column 114

 たぶん挙動的にExpatのバグのようだが、それを修正する気力もなかったので、とりあえずxmlrpclibでExpatを使わないようにすることで対処。たぶんパース速度は低下すると思われるが。具体的には、xmlrpclibをimportしたのち、XMLパーサーを取得するxmlrpclib.getparser関数を以下のようにして置き換える。

import xmlrpclib

# xmlrpclib's ExpatParser has bug, so do not use
org_getparser = xmlrpclib.getparser
def mygetparser(use_datetime=0):
    (p, t) = org_getparser(use_datetime)
    # if parser is ExpatParser, replace to SlowParser
    if isinstance(p, xmlrpclib.ExpatParser):
        p = xmlrpclib.SlowParser(p._target)
    return (p, t)
xmlrpclib.getparser = mygetparser

 ここでは、getparser関数の戻り値がExpatParserクラスのインスタンスだった場合、SlowParserクラスのインスタンスに置き換えるという処理をやっている。当然ながらかなりのdirty hackなので利用はおすすめしない。

Pythonコード中のSQL文インデントを考える

 Pythonコード内にSQL文を書くときどうすれば良いのか、いまいち答えが探せなかったのでググって見た話。

 ちなみに、今までは下記のような感じのコードを書いていた訳だが、これ見るからに分かりにくい。

# コード例その1
        cur.execute("""
          create table count (
            sid text,
            count int);""")

# コード例その2
        try:
            cur.execute("""insert into count ( sid, count )
                           values ( :sid, :count );""", d)
        except sqlite3.IntegrityError:
            cur.execute("""update count set sid = :sid, count = : count
                           where sid = :sid;""", d)
# コード例その3
        cmd = """select sid, title, date from stories where date >= ? and date < ? and sid in (
                 select sid from topics where topic == ? and sid in (
                 select sid from topics where topic == ? )) order by date
        """
        cur.execute(cmd, (begin_t, end_t, t1, t2))

 「SQL インデント」でググると、そういう話のネタが一杯出てくるでてくる。その中から拾ってみたのが下記。

 異端だけど、俺的コーディングルール SQL編 – suVeneのアレというのも参考になった。

 で、この辺をまとめたところ、だいたい以下のようなルールに落ち着いた。

  • SQLキーワードは大文字で
  • 括弧挟まれた部分はインデントレベルを+1する
  • カンマ、ANDの直後で改行
  • カンマやANDでつなげられている部分はなるべくキーワード部分でそろえる

 このルールで書いたコードは下記のような感じ。

# コード例その1
        cur.execute("""
            CREATE TABLE count (
                sid text,
                count int
            );
        """)

# コード例その2
        try:
            cur.execute("""
                INSERT INTO count (
                     sid,
                     count
                )
                VALUES (
                    :sid,
                    :count
                )
            """, d)
        except sqlite3.IntegrityError:
            cur.execute("""
                UPDATE count
                SET sid = :sid,
                    count = : count
                WHERE sid = :sid
            """, d)
# コード例その3
        cmd = """
            SELECT sid,
                   title,
                   date
            FROM stories
            WHERE date >= ? AND
                  date < ? AND
                  sid IN (
                      SELECT sid 
                      FROM topics
                      WHERE topic == ? AND
                      sid IN (
                          SELECT sid
                          FROM topics
                          WHERE topic == ?
                      )
                  )
            ORDER BY DATE
        """
        cur.execute(cmd, (begin_t, end_t, t1, t2))

 

 本当にこれで良いのかはまだ自信がないが、おおむね間違ってはいないと思う。ていうかコード内にSQL文を直書きせずO/Rマッパー使え、という話もあるが……。

要ログインのサイトにPythonのurllib2でアクセスする

 ログインが必要なWebサイトに対してスクリプトでページを取得/送信したい場合、まずログイン用のURLに対しログイン情報をPOSTしてCookieを取得する、という作業を行うのが一般的だ。しかし、Pythonのurllib2を利用すると簡単にPOSTは行えるのだが、なぜかCookieを取得できない、という問題が発生することがある。

 これはurllib2のurlopen()でURLを開き、帰ってきたオブジェクトのinfo()メソッド経由でSet-Cookieヘッダを取得しようとする場合に発生する。たとえばCMSを使用しているWebサイトで、管理用ページ以外に(正当な)Cookieを持たずにアクセスするとCookieなしでアクセスできるトップページ等にリダイレクトされる、というケース。多くのCMSではログインに成功すると管理ページトップにリダイレクトされるのだが、urllib2のデフォルト設定ではCookie処理を行ってくれない&リダイレクトを自動的に処理してくれるため、「ログイン成功→(Cookieを返すがurllib2はCookieを保存せず)→管理ページトップにリダイレクト→Cookieがないので公開ページのトップにリダイレクト→Cookieは受け取れず」という事態になってしまうことがある。

 この場合、urllib2.HTTPCookieProcessorを使ってCookie処理を行えばよいのだが、なぜかurllib2のドキュメントにはこのクラスの解説が無い。例にも上がっていない。ということで途方にくれるわけだが、実はCookieを扱うcookielibのほうに使い方の例が載っていたりする。

import cookielib
# : 
# (このへんでurlやencoded_data、headersを準備)
# :
req = urllib2.Request(url, encoded_data, headers)
cj = cookielib.CookieJar()
opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cj))
resp = opener.open(req)

 取得したCookieはCookieJarオブジェクトに保存されるので、必要に応じて適宜取り出せばOK。かわりにFileCookieJarオブジェクトを使えばファイルへのsave/loadも可能。

Pythonで被はてなブックマーク数を取得する

 Pythonのxmlrpclibモジュールで、指定したURLの被はてなブックマーク数を取得する例。簡単ですね。

 server.bookmark.getCount()はURLをキー、被はてブ数の値とするdictionaryの形で返してくれるので、返ってきたデータの参照も楽勝です。URLの一覧を引数の形で与えなければならないのがなんか引っかかりますが……。

#!/usr/bin/env python

import xmlrpclib


def main():
    uri = "http://b.hatena.ne.jp/xmlrpc"
    server = xmlrpclib.ServerProxy(uri)
    urls = ("http://sourceforge.jp/magazine/10/04/26/0244255",
             "http://sourceforge.jp/magazine/10/04/27/0326224",
             "http://sourceforge.jp/magazine/10/04/30/0243232")

    t = server.bookmark.getCount(*urls)
    for item in t:
        print item, t[item]

if __name__ == "__main__":
    main()

Pythonのwith構文と__enter__、__exit__

 Pythonのwith構文がいまいち掴めなかったので、ざっとまとめてみた(いまさらながら)。ドキュメントはPython リファレンスマニュアルの7.5 with 文にある。

 withを使ったコード例は、下記のような感じ。

c = ClassHogeHoge()
with c:
    c.foobar()

 上記のコードは、下記と等価となる。

c = ClassHogeHoge()
c.__enter__()
c.foobar()
c.__exit__()

 つまり、withに続くインデントブロックを実行する前に指定したオブジェクトの「__enter__()」メソッドを呼び出し、実行後に「__exit__()」メソッドが暗に呼び出される、という仕組み。

 __enter__()と__exit__()の定義は、Python リファレンスマニュアルの3.4.9 with文とコンテキストマネージャにある。__enter__()の引数はselfのみだが、__exit__()はself、exc_type、exc_value、tracebackの4つの引数をとる。withに続くインデントブロックが正常に実行された(つまり、例外が送出されなかった)場合、(self以外の)引数にはNoneが与えられる。なにか例外が発生した場合、その例外に関する情報が与えられるらしい。

 また、「with hogehoge as foo:」のような形でwith文を利用する場合、__enter__()の戻り値がfooに代入される。__exit__()の戻り値は例外処理の伝搬制御に使われ、Falseの場合例外が発生た場合でも例外を伝搬させず、Trueを返すと例外が伝搬されるとのこと。

 下記、使用例。

class CacheDB(object):
    DB_FILE = "database/db_dat"

    def __init__(self):
        self.con = None
        self.cur = None

    def __enter__(self):
        self.con = sqlite3.connect(self.DB_FILE)
        self.cur = self.con.cursor()

    def __exit__(self, exc_type, exc_value, traceback):
        if exc_type:
            self.con = None
            self.cur = None
            return False
        self.con.commit()
        self.cur.close()
        self.con.close()
        self.con = None
        self.cur = None
        return True

    def add(self, foo, bar, hoge):
        try:
            self.cur.execute("""insert into data ( foo, bar, hoge )
                           values (?, ?, ?);""", (foo, bar, hoge))

def main():
    usage = "%s logfile" % sys.argv[0]
    db = CacheDB()
    try:
        fname = args[0]
    except IndexError:
        sys.exit(usage)

    f = open(fname, "r")
    with db:
        for l in f:
            term = l.strip().decode("utf-8").rsplit("\t", 3)
            db.add(foo=term[0],
                   bar=term[1],
                   hoge=term[2])

if __name__ == '__main__':
    main()

 タブ区切りのデータファイルを1行ずつ読んでデータベースに突込む、という処理。withを使うことで、データベースアクセスの準備→データ挿入→コミットという流れをきれいに実装できました。

日本語リファレンスには書いてない話:urllibとurllib2の違いってなんだ

 Pythonでは、HTTPやFTPなどでファイルの送受信をするモジュールとして「urllib」と「urllib2」が用意されている。使い方も似ていて、どちらも引数としてURLを与えてurlopen()関数を呼び出すと自動的に対応したプロトコルでURLにアクセスしてデータを読み出せる、というものである。しかし、似たような名前で似たような機能を持つこの2つ、何が違うのかが明確にはドキュメントに記述されていない。そのためどちらを使うべきか迷って、そのたびにGoogleのお世話になるという状況だったのでざっとまとめてみよう。

外から見える違いとしては、urlopen()の引数としてurllibはURL文字列のみを受け付けるのに対し、urllib2ではurllib2.Requestクラスを受け取ることができる、という点がある。urllib2.Requestクラスはリクエストを抽象化したクラスで、これを利用することでたとえばHTTPでのデータ送受信の際、任意のHTTPヘッダーを送信させるようなことが可能になる。また、urllibでは使用するproxyを直接引数で指定できるが、urllib2ではRequestオブジェクトで指定することになる。

 ということで、ただHTTPやFTPでデータを取得したいだけならurllibでもurllib2でもどちらでも対して変わらない、HTTPヘッダーをカスタマイズしたいならurllib2を使え、という話になる。あと、urllibにはurlretrive()という関数があったり、quote()/unquote()/urlencode()などの便利そうなユーティリティ関数がある、というくらい。

独自プロトコルを実装したい場合は?

 いっぽう、挙動をカスタマイズしたい、独自プロトコルを実装したい、という場合は話が変わってくる。urllibもurllib2も独自プロトコルへの対応などのカスタマイズが可能なのだが、urllibはFancyURLopenerクラスの派生クラスで実装を行うのに対し、urllib2はBaseHandlerクラスの派生クラスで実装する。

 まず、urllibの場合。ドキュメントに記載されている例だが、たとえばUser-Agentを変更したい場合、下記のようにFancyURLopenerの派生クラスを作り、その初期化時に指定したいUserAgentをversion変数に代入するようにする。そして、この派生クラスのインスタンスをurllib._urlopenerに代入してやる。

import urllib

class AppURLopener(urllib.FancyURLopener):
    def __init__(self, *args):
        self.version = "App/1.7"
        urllib.FancyURLopener.__init__(self, *args)

urllib._urlopener = AppURLopener()

 これにより、以後はurllib.urlopen()などを呼び出した際、urllib._urlopener()で指定したインスタンスを使用してHTTP/FTPなどの処理が行われるようになる。

 ちなみに、FancyURLopenerとURLopenerの関係だが、HTTP/HTTPS/FTPなどの基本的な通信/データ取得処理はURLopener内で実装されており、FancyURLopenerはそれに各種HTTPレスポンスコードへの対応やリトライといった処理を実装したサブクラスとなっている。

 またドキュメントには記述されていないが、URLopenerにはaddheader()というメソッドが用意されており、ヘッダー名をキー、与えるデータを値にした辞書を引数として与えることで、任意のヘッダーを追加できる。

def addheader(self, *args):
        """Add a header to be used by the HTTP interface only
        e.g. u.addheader('Accept', 'sound/basic')"""
        self.addheaders.append(args)

 たとえばURLopenerの__init__()内では、次のようにしてUser-Agentを指定している。

self.addheaders = [('User-Agent', self.version)]

 なおURLopenerでは、urlopen()でHTTP/HTTPSのURLを与え、かつ第2引数が非Noneの場合(つまり、なんらかのデータを与えた場合)は「Content-Type: application/x-www-form-urlencoded」でのPOSTリクエストを利用するようにハードコーディングされている。そのため、ほかの形式でデータをPOSTしたい場合(ファイルを送信するなど)は、独自のURLopenerを作成する必要がある。

urllib2で独自プロトコルを使うには?

 いっぽうurllib2で独自のプロトコルを扱いたい場合、まずプロトコルを実装したBaseHandler()派生クラスを用意し、続いてそれを引数として与えてbuild_opener()を呼び出してURLハンドラを作成、最後にそれをinstall_opener()引数に与えて登録、という手順となる。下記はドキュメントに記載されている例だ。

import urllib2
# ベーシック HTTP 認証をサポートする OpenerDirector を作成する...
auth_handler = urllib2.HTTPBasicAuthHandler()
auth_handler.add_password('realm', 'host', 'username', 'password')
opener = urllib2.build_opener(auth_handler)
# ...urlopen から利用できるよう、グローバルにインストールする
urllib2.install_opener(opener)
urllib2.urlopen('http://www.example.com/login.html')

 なお、urllib2の(デフォルトの)HTTP/HTTPSハンドラでも、urlopen()の第2引数が非Noneの場合(つまり、なんらかのデータを与えた場合)は「Content-Type: application/x-www-form-urlencoded」でのPOSTリクエストを利用するようにハードコーディングされている。そのため、ほかの形式でデータをPOSTしたい場合(ファイルを送信するなど)は、やっぱり独自のハンドラを作成する必要がある。

 ちなみに、Python3系ではPython2系のurllibは廃止され、urllib2もurllib.requestという名称に変更されている。ということで、Python3系を見据えるならurllibではなくurllib2を利用するのが望ましいようだ。

Mako Templaters for Pythonメモ1:Makoってなに?

 最近Pythonのテンプレートエンジン「Mako」を触ってるんだけど、日本語の情報が全然ないのでまとめてみる。

Mako公式Webサイト

Mako公式Webサイト

Makoは「Hyperfast and lightweight templating for the Python platform.」(Pythonプラットフォーム向けの超高速で軽量なテンプレートエンジン)だ。Pythonのテンプレートエンジンとしては、Python標準ライブラリに含まれているstring.Templateや、WebフレームワークのDjangoに組み込まれているDjangoテンプレートエンジン、そしてCheetahなどが知られているが、Makoはそれらよりも高速で、テンプレート内にPythonコードを埋め込む機能や、キャッシュ機構などを備えてるのが特徴だ。また、文法もPython風であり習得しやすいのも利点だろう。

 いっぽう、特に大きな欠点は(いまのところ)見つかっていないのだが、日本語環境で利用する場合は文字コードをうまく扱うように適切にオプションを与える必要がある。

 ちなみに、MakoのWebサイトにはPython向けテンプレートエンジンのパフォーマンス比較が掲載されているのだが、ほかのテンプレートエンジンと比べてMakoは同等レベル以上に高速、という結果が出ているようだ。

Makoのインストール

 Makoはダウンロードページから行える。配布されているtar.gz形式のソースコードをダウンロードしてインストールできるほか、Pythonモジュール用のインストールマネージャ「easy_install」を利用してもインストールできる。

 ソースコードからインストールする場合は、ダウンロードしたアーカイブを展開し、次のように実行する。

# python setup.py install

 ちなみに、makoはすべてPythonコードで記述されているため、基本的にはPythonが動く環境であればどのプラットフォームでも動作するはずだ。

 つづく。

pyblosxom用のブログ記事をWordPressにインポートする

 以前このブログはPythonで書かれたブログシステム「pyblosxom」を使って運用していたんだけど、機能拡張が面倒臭かった(&色々細かいところが気にくわなかった)ので先日WordPressに移行しました。そこで面倒だったのが記事の移行。WordPressではMovableTypeとかBloggerとかからの移行ツールは充実しているんだけど、pyblosxomからの移行ツールは公式には用意されていません。また、参考情報としてWordPressの公式サイトで紹介されていたblosxomからの移行スクリプトはURLやタグ情報を移行してくれなかったため、結局自前で変換スクリプトを用意することに。

 幸い、WordPressのインポート/エクスポートで使われるWXL(WordPress XML)形式のファイルを簡単に生成できるモジュールをGoogle様が提供しているのを発見、そいつを使ってお気楽に作成できました。ということでここでご紹介。

 スクリプト本体はgithubで公開しているので必要な方は適宜どうぞ。「pyblosxom2wp.py」が本体です。このスクリプト内の頭で宣言している変数「title」にブログのタイトル、「link」にブログのリンクURL、「baseurl」をにBase URLが入るように書き換えて、次のように実行すると標準出力経由でインポート用XMLファイルが出力されます。

./pyblosxom2wp.py <pyblosxomの記事ディレクトリ> > output.xml

あとはそいつをWordPressのインポート機能で読みこめばOK、のはず。これで、カテゴリやタグ、URLについてもできるだけ保持する形でインポートが可能です。

Pythonネタ:unittestを使う

 Python標準のユニットテスト機能、「unittest」の使い方メモ。

目的

 unittestはPythonで作成したクラスの特定の関数や、機能の動作確認に利用できる機能だ。詳しくは ドキュメントを読めばすぐに分かる が、unittestクラスの派生クラスを作り、そのクラスの関数としてテストコードを記述してやると、簡単にユニットテストができる、というもの。

 メインの実装コードにprint文などを挿入したり、テストコードを挿入しても良いのだが、それだとテストコードの再利用が難しかったり、いったんバグ修正を行ってテストコードを削除した後に再度バグが発生したりした場合に二度手間になったりする。そのため、テストコードはなるべくunittestにまとめて記述しておくとデバッグや実装、テストが楽になりますよ、というお話。

使い方

 基本的な使い方はこれまたオンラインドキュメントにあるのだが、自分は下記の形をよく使っている。

import random
import unittest

class TestSequenceFunctions(unittest.TestCase):
    def setUp(self):
        # ここに各テスト関数を実行する前に呼び出す処理を書く。
        # 通常は共通のデータの準備とかを書くことが多い


   def tearDown(self):
        # ここに各テスト関数を実行した後に呼び出す処理を書く。
        # 通常は共通のデータの後片付けとかを書くことが多い


    def test_hogehoge(self):
        """test for hogehoge テストの内容をコメントに入れる"""
        # テストコード1をここに書く


    def test_foobar(self):
        """test for foobar テストの内容をコメントに入れる"""
        # テストコード2をここに書く


# do unittest
# テストオブジェクトを作成
suite = unittest.TestLoader().loadTestsFromTestCase(TestSequenceFunctions)

# テスト実行。出力するメッセージレベルはverbosity引数で設定できる
unittest.TextTestRunner(verbosity=2).run(suite)

 テストコード内でテストが期待したとおりの処理を行っているかどうかは、unittest.TestCaseクラス内で用意されているasert/fail関数を使うのが好ましい。詳しくは「pydoc unittest」等で確認できるが、たとえば二つの引数の値が等しくない場合にエラーを出すには「assertEqual(引数1、引数2、エラーメッセージ)」関数を使う。

 そのほか、unittestにはレポート機能などもあるが、基本的には上記さえ押さえておけば事足りるはず。

Pythonのクラスの挙動を調べる:class構文の外でクラスにメンバ関数を追加(2)

 今度は、別のモジュールで定義したクラスにメンバ関数を追加してみる。

[Macintosh:~]$ python
Python 2.5.1 (r251:54863, Feb  6 2009, 19:02:12)
[GCC 4.0.1 (Apple Inc. build 5465)] on darwin
Type "help", "copyright", "credits" or "license" for more information.

   # importすることで定義されたクラスは、その名前空間内に閉じこめられる 

>>> import foobar
>>> print foobar.FooBar
<class 'foobar.FooBar'>

   # 名前空間が異なっていても、名前空間さえ指定すればメンバ関数を追加できる 

>>> f = foobar.FooBar()
>>> f.show_name()
I'm FooBar.
>>> def rename(self,new_name):
...     self.name = new_name
...
>>> foobar.FooBar.rename = rename
>>> f.rename("john")
>>> f.show_name()
I'm john.

 大体予想通りの結果ですな。

PythonでCGI経由でファイルアップロード

 Webベースで記事作ったりサイトデザインしたりしていると、多量のファイルをアップロードする機会も多々あるのでファイルを自動アップロードするスクリプトを書きたい、という話。

 とりあえずググったら「 残高照会メモ: pythonでアップロード 」が出てきたのだが、自前でMIMEエンコーディングしなきゃいけないのがちょっとアレだ。

 ちなみにPerlだと下記のような感じでいける。

sub post_attachment {
    my $self = shift @_;
    my %args = @_;

    my $file_name = $args{file};  # filename

    my $url = "アップロード先URL";
    my $ua = LWP::UserAgent->new( 'agent' => "適当なUserAgent文字列", );
    my $req = HTTP::Request::Common::POST $url, Content_Type => 'form-data',
    Content => [
				file_content => ["$file_name"],
				description => "",
				op => "addFileForStory",   # form "op"の値
				sid => $sid,               # form "sid"の値
				Submit => "Submit",        # form "Submit"の値
	];

    my $resp = $ua->request( $req );

    if( $resp->is_error ) {
	return 0; #print "upload $file_name: failed.\n";
    } else {
	return 1; #print "upload $file_name: succeed.\n";
    }
}

 これをPythonでやりたい。ということで続く。