Python で Rake を真似るとしたら という反応を頂いたので、それにまつわるお話を。

まず、CodeReposにコミットしてあるtasktoolsは distutils及びsetuptoolsを拡張する ということを念頭に置いています。ですのである程度distutils.core.Commandの思想というか、インターフェイスを残しています。

  • タスクをクラスで定義すること
  • オプションの定義方法
  • sub_commandsfinalize_options といったメソッド

などなどは元のまんまです。


じゃあ、 distutilssetuptools を抜いて好きにRakeをPythonで真似るとしたら、というのが今回メインのお話。最初にこんな感じでコマンドを定義しますよ、というのを出してしまいます。

from __future__ import with_statement
from 今回作成したモジュール import *
from subprocess import call

# ここからタスク定義

with namespace("test"):

  @task
  def build(t, depends = ["test:make"]):
    """builds this modules. """
    call(["ls"])
    print t.name, ": build done"

  @task
  def make(t):
    print "make"

# 実行
run()

Rakeだとこうなる。

namespace "test" do

  desc "builds this modules"
  task "build" => "make" do |t|
    sh("ls")
    puts "#{t.name} done."
  end

  task "make" do |t|
    puts "make"
  end

end

Python版もなかなかシンプルで見やすいと思うんですがどうでしょう。あとちょっとtips。さっきの記事だと os.system を使ってますが、 subprocess モジュールの callcheck_call のほうが便利じゃないかな、と思います。

タスクのメタ情報は引数のデフォルト値を使います。ここで @task(depends = ["test:make"]) ってしたほうがいいんじゃないの?と思う人もいるかもしれません。真っ当な意見だと思いますし、それがPythonicなやり方と思います。しかしそうするとこうなります。

with namespace("test"):

  @task(depends = ["test:make"])
  def build(t):
    """builds this modules.  """
    call(["ls"])
    print t.name, ": build done"

  @task() #<= ここがキモくないですか?
  def make(t):
    print "make"

Pythonではproperty以外では () をつけないとメソッドが呼び出せません。だから引数が何もない場合は空のカッコがつくわけです(まぁDescriptorでhogehogeすればできるんですがね)。これ、イケてないですよね。というわけで引数のデフォルト値を使います。

ではこのタスク定義でタスクを実行するためのコードです。

from __future__ import with_statement
import inspect,new,sys
from contextlib import contextmanager
from optparse import OptionParser
from itertools import *

class _Tasks(dict):
  def __getitem__(self,key):
    if key in self: return dict.__getitem__(self, key)
    _abort("Error: Unknown task: %s"%key)

class Task(object):
  def __init__(self, run, name, description, 
               depends = None, options = None):
    self._run = new.instancemethod(run, self, self.__class__)
    self.name = _get_current_ns()+name
    self.description = description
    self.depends = depends or []
    self.options = options or []
    tasks[self.name] = self

  def run(self):
    for depend in self.depends:
      if tasks[depend].run() is False:
        _abort("Error: faild %s"%depend)
    return self._run()

_namespace = []
tasks = _Tasks()

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

def task(f):
  names, _, _, values = inspect.getargspec(f)
  Task(f, f.__name__, f.__doc__ or "no description", **dict(izip(names[1:], values or [])))

def print_tasks():
  maxlength = max(imap(len, tasks.iterkeys()))
  for name in sorted(tasks.iterkeys()):
    print name.ljust(maxlength), "#", tasks[name].description.strip()

def run():
  usage = "usage :%prog [options] targets"
  parser = OptionParser(usage)
  parser.add_option("-T", "--tasks", action="store_true",
                    dest="print_tasks",
    help="Display the tasks (matching optional PATTERN) with descriptions, then exit.")
  (options, args) = parser.parse_args()
  if options.print_tasks: _abort(print_tasks())
  if not args:            _abort(parser.get_usage())

  map(lambda t:t.run(), imap(tasks.__getitem__, args))

def _abort(msg):
  if msg : print msg
  sys.exit(1)

def _get_current_ns(): return _namespace and ":".join(_namespace)+":" or ""

結構短いかな?。一応ちゃんと動きます。

tasks.py -T

test:build # builds this modules.
test:make  # no description

tasks.py test:build

make
tasks.py
test:build : build done

うむ。


結局、Rubyの言語内DSL構築能力は

  • () が省略できる
  • ブロック(pythonではデコレータとwith_statementがありますが)

によるものが多いと思っています。ことPythonとの対比では。「シンプルさ」「書きやすさ」なんかは好みかな、と(Rakeの例だとdocstringを持っている分Pythonの方がいい感じさえしてきます)。例えば end ってなんだよ、って言う人にはPythonのほうがウケがいいかもしれませんしそうじゃないかもしれません。

そんなこんなで試しに書いてみたらいい感じだったんで、ちゃんと本格的にPython版Rakeとして開発を続けてみようかなあ。


ところで言語内DSLといえばScalaでしょう。Rubyなんてちょろいもんですよ。なんてったってScalaは(ry


以前紹介したPython版Rakeもどき を改良してCodeReposに突っ込みました。まだ100行くらいです。自分では一番使っている自作モジュールなのでそろそろまとめようと思っていたのです。

http://coderepos.org/share/browser/lang/python/tasktools/trunk/tasktools.py

改良点

  • setuptools があるときはそっちを使うようにした。
  • use_without_standard メソッドによって distutils 標準タスクを消すことができる。これによって --help-commands の画面がすっきりする。また、この場合名前空間のセパレータに : を用いるようになった。
  • global_description によってタスクファイル自体に説明がつけられるようになった。
  • load_path メソッドで指定したディレクトリ以下を再帰的に検索し「 tasks.py 」という名前のファイルを読み込むことができるようになった。
  • --help-commands で表示されるコマンドの並び順をソートするようにした。
  • --help-commands でサブコマンドを一覧表示するようにした。

使用方法

以前書いたのとほとんど同じなんですが、まとめなおしておきます。

tasktoolsとは?

distutils および setuptools を拡張してextra commandを簡単に作成するためのユーティリティです。RubyにおけるRakeのようなものです。ビルド機能がほしい場合は distutils , setuptools の標準ビルド機能、もしくは SCons と組み合わせるとハッピーになれます。

ちなみに、 tasktools というのは同じく distutils の拡張である setuptools の命名規則に習っています。 task 機能を強化するから tasktools です。

チュートリアル

典型的な tasktools の使い方です。

まず tasks.py というファイルを作成します。典型的には以下の様になります。

from __future__ import with_statement
from tasktools import *

global_description(u"""
サンプルタスクファイルです。
""")

use_without_standard() # distutilsの標準コマンドを使用しないことを宣言します。
load_path("./tasks") # "./tasks"以下のtasks.pyを再帰的に読み込みます


with namespace("file") as ns:
  class mktmpfile(Task):
    u"""一時ファイルを作成します。
    """
    user_options = [("path=", "p", u"作成するパスです")]
    def run(self):
      print "create %s"%self.path

    def finalize_options(self):
      if not self.path: self.path = "/tmp/tmp.txt"

  class mklogfile(Task):
    u"""ログファイルを作成します。
    """
    def run(self):
      print "create log file"

  class init(Task):
    u"""ファイルを初期化します。
    """
    def run(self):
      pass

    sub_commands = [("file:mktmpfile", None),
                    ("file:mklogfile", None)]

if __name__ == "__main__":
  run()

では python tasks.py --help-commands と実行してみましょう

############################################################

サンプルタスクファイルです。

############################################################

Commands:
  file:init        ファイルを初期化します。
        sub commands:
                file:mktmpfile
                file:mklogfile
  file:mklogfile   ログファイルを作成します。
  file:mktmpfile   一時ファイルを作成します。

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

この様に global_description で設定した説明と、定義したタスクの一覧が表示されます。

次に ./tasks/tasks.py を作成してみます。

from __future__ import with_statement
from tasktools import *

use_without_standard()
with namespace("subs") as ns:
  class test1(Task):
    u"""サブディレクトリで定義されたタスクです
    """
    def run(self):
      print "sub test"

if __name__ == "__main__":
  run()

このファイルは ./tasks.pyload_path("./tasks") と宣言しているので ./tasks.py を実行すると自動的に読み込まれます。 もう一度 python tasks.py --help-commands と実行してみましょう

############################################################

サンプルタスクファイルです。

############################################################

Commands:
  file:init        ファイルを初期化します。
        sub commands:
                file:mktmpfile
                file:mklogfile
  file:mklogfile   ログファイルを作成します。
  file:mktmpfile   一時ファイルを作成します。
  subs:test1       サブディレクトリで定義されたタスクです

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

確かに subs:test1 コマンドが追加されています。

ではタスクを実行してみましょう。 python tasks.py file:init を実行してみます。

running file:init
running file:mktmpfile
create /tmp/tmp.txt
running file:mklogfile
create log file

おお、実行されましたね。

user_optionsを定義しているタスクではオプションも渡せます。 python tasks.py file:mktmpfile --path=/tmp/change.txt を実行してみましょう。

running file:mktmpfile
create /tmp/change.txt

ちゃんとオプションが渡されていますね。


こんな感じです。distutilsの独自コマンドに関する説明は 46 新しいDistutilsコマンドの作成 を参照してください。正直使えないページですが・・・。一応説明しておくとinitialize_optionsはuser_optionsの定義から自動生成するようになっています。また distutils.core.Command を継承しているのでこのクラスの機能も使えます。

せっかくCodereposに突っ込んだのでバグなんかが見つかったらガンガン直しちゃってください。


ScalaにはStreamという無限リストがあるんだけど、微妙に使いづらい、というか分かりづらい。Haskellでいうcycleはどうだ、とかよく忘れるのでメモ。

def repeat[T](a:T) = Stream.const(a)
def cycle[T](a:Iterable[T]) = Stream.const(a).flatMap(v=>v)
def iterate[T](f:T => T,  x:T):Stream[T] = Stream.cons(x, iterate(f, f(x)))
def replicate[T](n:int, elem:T) = Stream.make(n, elem)

こんな感じかな。cycleは結構使うから、Streamに標準でありそうな気がするんだけど、ないような。というわけで上のような定義となる。

repeat(1) take 10 print
// => 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, Stream.empty

cycle(1 to 4) take 10 print
// => 1, 2, 3, 4, 1, 2, 3, 4, 1, 2, Stream.empty

iterate((x:int)=>x+1, 0) take 10 print
// => 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, Stream.empty

replicate(3, 1) take 10 print
// => 1, 1, 1, Stream.empty

うんうん。