Date: 2012-04-11
Tags: python, make, makefile, library

MakefileをPythonで置き換える

仕事でMakefileを書いていたのですが、ちょと複雑なことをしようとすると書き方を調べるのにやたらと時間を取られます。Pythonばっかり書いてるせいか、Makefileやshell scriptの書き方は毎回忘れますね。

MakefileをPythonで書ければ良いのに! とはいえ、ベタにPythonで書いて、ターゲット間の依存関係などを考え始めるとコードがめちゃくちゃ長くなってしまいます。ということでPythonでMakefileを置き換えるツールが無いか調べてみました。ざっと探して見つけたのが以下のツールです。

この中では、多分欲しいのはPaver辺りだろうなあと思いつつ、自分が欲しいのはどういう使い方なのかを把握するためにコードを書いてみました。

以下のようなmake.pyを記述できると、書くのが楽そうです。

# -*- coding: utf-8 -*-
from __future__ import absolute_import, print_function

import os
import sys
import time
import tarfile

from .mk import make


#defines
WORK_DIRS = ['bin', 'parts', 'develop-eggs']
EGG_DIR = 'eggs'
BUILDOUT_CMD = 'bin/buildout'
if sys.platform.startswith('win'):
    BUILDOUT_CMD += '.exe'


@make.target() #this is make target.
@make.depend('buildout') #run dependents before target
def all():
    return make.sh(BUILDOUT_CMD, '-N') #call shell command


@make.target(BUILDOUT_CMD) #check existent
def buildout():
    make.mkdir(EGG_DIR) #make dir if not exist
    return make.sh(
        sys.executable, '-S', 'bootstrap.py',
        '-d',
        '--eggs', EGG_DIR,
        '--setup-source', 'distribute_setup.py',
        '--version', '1.5.2',
    )


@make.target()
def clean():
    make.rm(*WORK_DIRS) #remove directories if exist


@make.target()
def remove_eggs():
    make.rm(EGG_DIR)


@make.target()
@make.depend('clean', 'remove_eggs', 'buildout')
def pkg():
    make.sh(BUILDOUT_CMD, '-c', 'buildout-mkpkg.cfg')
    make.call('clean') #call another make target

    name = os.path.basename(os.getcwd())
    filename = '{0}-{1}.tgz'.format(name, time.strftime('%Y%m%d-%H%M%S'))
    with tarfile.open('../'+filename, 'w|gz') as tar:
        tar.add('.', name)

    print('deploy package:', filename)


if __name__ == '__main__':
    make.run()

これを動かすためのmk.pyをざざっと実装してみました。

# -*- coding: utf-8 -*-
from __future__ import print_function

import os
import sys
import subprocess
import shutil


class Runner(object):
    def __init__(self, func, depends=None, targets=None):
        if isinstance(func, self.__class__):
            self.func = func.func
            self.depends = depends or func.depends or []
            self.targets = targets or func.targets or []
        else:
            self.func = func
            self.depends = depends or []
            self.targets = targets or []
        self.name = self.func.__name__

    def __call__(self, *args, **kw):
        # targets
        if self.targets and __builtins__.all(
                os.path.exists(target) for target in self.targets):
            print("Skip:", self.name)
            print(" all targets are exists:", self.targets)
            return 0

        # depends
        for depend in self.depends:
            ret = make.call(depend)
            if ret:
                return ret
        # main
        print("In:", self.name)
        ret = self.func(*args, **kw)
        print("Out:", self.name)
        return ret


class make(object):
    """class for namespace"""
    __commands = {}

    #private
    @classmethod
    def _register(cls, func, depends=None, targets=None):
        func = Runner(func, depends=depends, targets=targets)
        cls.__commands[func.name] = func
        return func

    #decorator
    @classmethod
    def depend(cls, *depends):
        def inner(func):
            return cls._register(func, depends=depends)
        return inner

    #decorator
    @classmethod
    def target(cls, *targets):
        def inner(func):
            return cls._register(func, targets=targets)
        return inner

    #utility
    @classmethod
    def sh(cls, *args):
        print(' '.join(args))
        return subprocess.check_call(args)

    #utility
    @classmethod
    def rm(cls, *dirs):
        for d in dirs:
            shutil.rmtree(d, True)

    #utility
    @classmethod
    def mkdir(cls, path):
        if not os.path.exists(path):
            os.makedirs(EGG_DIR)

    #utility
    @classmethod
    def call(cls, name, args=[], kw={}):
        return cls.__commands[name](*args, **kw)

    #utility
    @classmethod
    def run(cls):
        if len(sys.argv) < 2:
            if 'all' in cls.__commands:
                sys.argv.append('all')
            else:
                print(sys.argv[0], 'need target.')
                for key in sorted(cls.__commands.keys()):
                    print(' ', key)
                sys.exit(-1)

        target = sys.argv[1]
        try:
            ret = cls.call(target)
        except:
            print('Error in', target)
            raise

        if ret:
            print('Error in', target, '::', ret)
        if not isinstance(ret, int) and ret is not None:
            ret = -1
        sys.exit(ret)

なぜかclassmethodだらけ。使い方のわかりやすさ優先で実装したらなぜかこんなコードになりました。

これで以下のように実行できます。

$ python make.py all
...
$ python make.py pkg
...
deploy package: maketest-20120406190450.tgz
$

make all に比べると多少入力文字数が多くなりましたが、既存の手順を維持したいなら以下のようなMakefileを用意しておけば良いですね。

all:
    python make.py all

pkg:
    python make.py pkg

...

あとは、先のコードに近い記述ができるツール(=自分が使いやすいと感じるツール)を前述の4つから探せば良いわけですが、とりあえずの目的は達成しちゃったので調べるのはまた今度にします。

ソースコード

作ったコードは以下から取得出来ます。