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

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

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

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

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

 


 

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

PYTHON:
  1. from __future__ import with_statement
  2. from 今回作成したモジュール import *
  3. from subprocess import call
  4.  
  5. # ここからタスク定義
  6.  
  7. with namespace("test"):
  8.  
  9.   @task
  10.   def build(t, depends = ["test:make"]):
  11.     """builds this modules. """
  12.     call(["ls"])
  13.     print t.name, ": build done"
  14.  
  15.   @task
  16.   def make(t):
  17.     print "make"
  18.  
  19. # 実行
  20. run()


Rakeだとこうなる。

RUBY:
  1. namespace "test" do
  2.  
  3.   desc "builds this modules"
  4.   task "build" => "make" do |t|
  5.     sh("ls")
  6.     puts "#{t.name} done."
  7.   end
  8.  
  9.   task "make" do |t|
  10.     puts "make"
  11.   end
  12.  
  13. end


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

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

PYTHON:
  1. with namespace("test"):
  2.  
  3.   @task(depends = ["test:make"])
  4.   def build(t):
  5.     """builds this modules.  """
  6.     call(["ls"])
  7.     print t.name, ": build done"
  8.  
  9.   @task() #<= ここがキモくないですか?
  10.   def make(t):
  11.     print "make"


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

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

PYTHON:
  1. from __future__ import with_statement
  2. import inspect,new,sys
  3. from contextlib import contextmanager
  4. from optparse import OptionParser
  5. from itertools import *
  6.  
  7. class _Tasks(dict):
  8.   def __getitem__(self,key):
  9.     if key in self: return dict.__getitem__(self, key)
  10.     _abort("Error: Unknown task: %s"%key)
  11.  
  12. class Task(object):
  13.   def __init__(self, run, name, description,
  14.                depends = None, options = None):
  15.     self._run = new.instancemethod(run, self, self.__class__)
  16.     self.name = _get_current_ns()+name
  17.     self.description = description
  18.     self.depends = depends or []
  19.     self.options = options or []
  20.     tasks[self.name] = self
  21.  
  22.   def run(self):
  23.     for depend in self.depends:
  24.       if tasks[depend].run() is False:
  25.         _abort("Error: faild %s"%depend)
  26.     return self._run()
  27.  
  28. _namespace = []
  29. tasks = _Tasks()
  30.  
  31. @contextmanager
  32. def namespace(name):
  33.   _namespace.append(name)
  34.   yield
  35.   _namespace.pop()
  36.  
  37. def task(f):
  38.   names, _, _, values = inspect.getargspec(f)
  39.   Task(f, f.__name__, f.__doc__ or "no description", **dict(izip(names[1:], values or [])))
  40.  
  41. def print_tasks():
  42.   maxlength = max(imap(len, tasks.iterkeys()))
  43.   for name in sorted(tasks.iterkeys()):
  44.     print name.ljust(maxlength), "#", tasks[name].description.strip()
  45.  
  46. def run():
  47.   usage = "usage :%prog [options] targets"
  48.   parser = OptionParser(usage)
  49.   parser.add_option("-T", "--tasks", action="store_true",
  50.                     dest="print_tasks",
  51.     help="Display the tasks (matching optional PATTERN) with descriptions, then exit.")
  52.   (options, args) = parser.parse_args()
  53.   if options.print_tasks: _abort(print_tasks())
  54.   if not args:            _abort(parser.get_usage())
  55.  
  56.   map(lambda t:t.run(), imap(tasks.__getitem__, args))
  57.  
  58. def _abort(msg):
  59.   if msg : print msg
  60.   sys.exit(1)
  61.  
  62. 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


About this entry