Python標準モジュールでRakeもどき

誰もが一度使うと便利さと気軽さに感動するRubyが誇るライブラリ、 Rake

プログラムのビルドもそうなんですが、雑多なタスクを簡単に書けて、整理できるのがなんといっても魅力的。RailsなんかではDBの作成から何から、ばんばんRakeタスクにされていますよね。

 

さて、俺はPythonistasなので、PythonでRakeみたいなのがほしいわけです。ビルドに限っていえばPythonは Scons という素晴らしいツールがあります。C言語はおろか、JAVA、PDF、PostScriptなどなど、さらにはSubversionもサポートしていますし、並列コンパイルもでき、実績も多数で申し分ありません。

でも俺がしたいのは、雑多なタスクを放り込む、コレ。そういうのを簡単にやるライブラリってPythonではないんでしょうか。RubyではRakeが標準添付されるというのに。いえ、あります。前からあるんです。それが distutils

distutils(およびその拡張のsetuptools)はPythonの標準的なパッケージ配布システムとして有名です。でも実は自分でコマンドを定義してRakeのように使うことができることはあまりしられていないのではないでしょうか。

distutilsでコマンドを定義する

distutilsでコマンドを定義するにはdistutils.core.Commandクラスを継承し、色々オーバーライドします。説明もつけられますし、オプションもとれて、sub_commandsで依存関係(コマンド分割)を設定することもできます。

class MyCommand(Command):
  description = "コマンドの説明"
  user_options = [("host=", "h", "hostname")] # オプション
  sub_commands = [("pre_task", None)] # サブコマンド
  def run(self): pass # 実行する内容
  def initialize_options(self): # オプションの初期化
    self.host = None
  def finalize_options(self):
    if not self.host: raise "Error"

setup(cmdclass={"my_command":MyCommand})

といった具合です。非常にPythonicなやり方ですね。ただ、いろいろオーバーライドしないといけないので面倒です。そこで、俺がコピペで使っている自家製テンプレの登場です。

もっと簡単にdistutilsでタスク

30行程度のテンプレを足して、簡単にタスクを定義できるようにしましょう。namespace機能はPython2.5以上限定です。

from __future__ import with_statement
from distutils.core import Command, setup
from contextlib import contextmanager

_cmds = {}
_namespace = []
_get_ns = lambda:_namespace and "_".join(_namespace)+"_" or ""
class CommandType(type):
  def __new__(cls, class_name, class_bases, classdict):
    d = dict(user_options=[], finalize_options=lambda s:None)
    d.update(classdict)
    def _(self):
      [setattr(self,i[0].rstrip("="),None) for i in d["user_options"]]
    d["initialize_options"] = _
    d["boolean_options"] = [i for i,j,k in d["user_options"] if not i.endswith("=")]
    def _(self):
      map(self.run_command, self.get_sub_commands())
      return classdict["run"](self)
    d["run"] = _
    name = _get_ns()+class_name.lower()
    cls = type.__new__(cls, name, class_bases + (object,), d)
    cls.description = cls.__doc__
    if class_name != "BaseCommand" : _cmds[name] = cls
    return cls
class BaseCommand(Command): __metaclass__ = CommandType

@contextmanager
def namespace(name):
  _namespace.append(name)
  yield _get_ns()
  _namespace.pop()

これだけです。これでかなり簡単にタスクが作れるようになります。例を示しましょう。

with namespace("db") as ns:
  with namespace("create") as ns:
    class Test1(BaseCommand):
      """test1 discription
      """
      user_options = [ ("host=", "h", "hostname"),
                      ("port=", "p", "port"),
                      ("force", "f", "force execute")]
      def run(self):
        print self.host
        print self.force
        print "hello, ", self.__class__.__name__

      sub_commands = [(ns+"test2",None)]

    class Test2(BaseCommand):
      """test2 discription
      """
      def run(self):
        print "hello, ", self.__class__.__name__


  class Test3(BaseCommand):
    """test3 discription
    """
    def run(self):
      print "hello, ", self.__class__.__name__

class Test4(BaseCommand):
  """test4 discription
  """
  user_options = [ ("host=", "h", "hostname") ]
  def run(self):
    print "hello, ", self.__class__.__name__

  def finalize_options(self):
    if not self.host:
      raise ValueError("host must not be None")


setup(cmdclass=_cmds)

かなり直感的になったのではないでしょうか。withを使えば名前空間も結構簡単に実現できます。コマンドのヘルプを見てみましょう。

# python task.py --help-commands
Standard commands:
  build            build everything needed to install
  build_py         "build" pure Python modules (copy to build directory)
  build_ext        build C/C++ extensions (compile/link to build directory)
  build_clib       build C/C++ libraries used by Python extensions
  build_scripts    "build" scripts (copy and fixup #! line)
  clean            clean up temporary files from 'build' command
  install          install everything from build directory
  install_lib      install all Python modules (extensions and pure Python)
  install_headers  install C/C++ header files
  install_scripts  install scripts (Python or otherwise)
  install_data     install data files
  sdist            create a source distribution (tarball, zip file, etc.)
  register         register the distribution with the Python package index
  bdist            create a built (binary) distribution
  bdist_dumb       create a "dumb" built distribution
  bdist_rpm        create an RPM distribution
  bdist_wininst    create an executable installer for MS Windows

Extra commands:
  db_test3         test3 discription

  db_create_test2  test2 discription

  db_create_test1  test1 discription

  test4            test4 discription


usage: task.py [global_opts] cmd1 [cmd1_opts] [cmd2 [cmd2_opts] ...]
  or: task.py --help [cmd1 cmd2 ...]
  or: task.py --help-commands
  or: task.py cmd --help

こんな感じで、標準コマンドに加え「Extra commands」という形で定義したタスクが使えるようになります。もちろん、distutilsのビルド機能、パッケージ管理機能も使えますよ。

db_create_test1のヘルプを見てみましょう。

# python task.py db_create_test1 --help
Common commands: (see '--help-commands' for more)

  setup.py build      will build the package underneath 'build/'
  setup.py install    will install the package

Global options:
  --verbose (-v)  run verbosely (default)
  --quiet (-q)    run quietly (turns verbosity off)
  --dry-run (-n)  don't actually do anything
  --help (-h)     show detailed help message

Options for 'db_create_test1' command:
  --host (-h)   hostname
  --port (-p)   port
  --force (-f)  force execute

usage: task.py [global_opts] cmd1 [cmd1_opts] [cmd2 [cmd2_opts] ...]
  or: task.py --help [cmd1 cmd2 ...]
  or: task.py --help-commands
  or: task.py cmd --help

ちゃんとオプションが表示されていますね。最後に、db_create_test1タスクを実行してみましょう。

# python task.py db_create_test1 --host=localhost
running db_create_test1
running db_create_test2
hello,  Test2
localhost
None
hello,  Test1

サブコマンドのdb_create_test2が実行されていること、きちんとオプションがselfの属性として設定されていることがわかりますね。


標準的な機能だけでもRakeのようなタスクはつくれますが、30行程度の工夫で断然便利になります。Rakeと違いrakeコマンドのような外部コマンドも必要なく、pythonのスクリプト1つで実現できるのも手軽です。というわけで、雑多なタスクはdistutilsでまとめてみてはいかがでしょうか。

comments powered by Disqus