Python,Rubyの言語内DSL構築力:PythonでRakeをまねる場合を例に

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

comments powered by Disqus