2014-03-31

Python: Portablize Sphinx

そろそろ、以前から気になっていた Sphinx を触ってみようかと。スタンドアロン版ソフトウェアをこよなく愛す私は、当然のようにスタンドアロン版を選択。(以降、スタンドアロン版インストーラー SphinxInstaller-1.2.20131210-py2.7-win32.zip を使用)

しかし使ってみて、コレジャナイ・・・orz。うん、私が悪かった。「スタンドアロン」ではなくて、「ポータブル」と言うべきだった。上記はスタンドアロンではあるが、ポータブルではない。具体的には、インストールしたディレクトリを別の場所に移すと動かなくなる。これでは zip で固めてメンバーへ配布、という訳にはいかない。

調べると、$SPHINX_HOME/bin/sphinx-*-script.py 中に絶対パスが記述されているのが原因のようだ。特に shebang 行がまずい。shebang 行は sphinx-*.exesphinx-*-script.py を実行するために使用しているようだが、shebang は絶対パスでないと動かないらしい。

ここで諦めようとも思ったが、何か悔しいので「起動時に毎回 sphinx-*-script.py を書き換える」という力技に出てみた。

$SPHINX_HOME/sphinx.bat:

@echo off
setlocal

set _dp0=%~dp0
set SPHINX_HOME=%_dp0:~0,-1%
set _dp0=
set PYTHON_HOME=%SPHINX_HOME%\python
set PATH=%PATH%;%SPHINX_HOME%\bin;%PYTHON_HOME%;%PYTHON_HOME%\Scripts

python.exe "%SPHINX_HOME%\bin\setup-portable.py" -x >NUL
if errorlevel 1 (
  echo ERROR: failed to setup
  pause
  exit 1
)
title PortableSphinx
echo *** Welcome to PortableSphinx! ***
echo.
cmd.exe /k

$SPHINX_HOME/bin/setup-portable.py:

#!python.exe

__doc__ = """\
Usage:
  python %s -x
Description:
  Setup PortableSphinx.  This should be executed first whenever the
  Sphinx directory is moved on your PC.
""" % __file__

import filecmp
import glob
import os
import re
import shutil
import sys
import tempfile

_sphinx_home = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

def _create_tempfile(lines=None):
    path = ''
    try:
        with tempfile.NamedTemporaryFile(delete=False) as fh:
            path = fh.name
            if lines:
                fh.writelines(lines)
        return path
    except StandardError:
        _remove_file(path)
        raise

def _remove_file(path):
    try:
        os.remove(path)
    except StandardError:
        pass

def _read_file(path):
    with open(path) as fh:
        return fh.readlines()

def _write_file(path, lines):
    tmp_path = _create_tempfile(lines)
    try:
        if not filecmp.cmp(tmp_path, path):
            shutil.copyfile(tmp_path, path)
    finally:
        _remove_file(tmp_path)

def _convert_script(lines):
    lines = iter(lines)
    for line in lines:
        yield '#!"%s"\n' % os.path.join(_sphinx_home, 'python', 'python.exe')
        break
    sphinx_home = _sphinx_home.replace('\\', '\\\\\\\\')
    re_library = re.compile(r"^(\s*').+?(?=\\\\(eggs|python)\\\\)", re.I)
    for line in lines:
        yield re_library.sub(r'\1' + sphinx_home, line)

def _setup_bin():
    script_glob = os.path.join(_sphinx_home, 'bin', 'sphinx-*-script.py')
    script_paths = glob.glob(script_glob)
    if not script_paths:
        raise RuntimeError('not found: %s' % script_glob)
    for script_path in script_paths:
        lines = _read_file(script_path)
        _write_file(script_path, _convert_script(lines))

def main():
    status = 1
    args = sys.argv[1:]
    if args and args[0] == '-x':
        _setup_bin()
        print 'OK'
        status = 0
    else:
        print __doc__,
    return status

if __name__ == '__main__':
    sys.exit(main())

スタンドアロン版 Sphinx のインストール先に上記を突っ込んで、毎回 sphinx.bat から起動するようにすれば、擬似 PortableSphinx の出来上がり。後はディレクトリを zip で固めればそのままプロジェクトメンバーに配布できる。今のところ、私が使う範囲で不具合は出ていない。(不具合がないとは言っていない)

さて、実際に Sphinx を使ってみると、確かにこれはなかなか良い。懸念していた日本語ファイル名(CP932)も全く問題ないし、印刷時の改頁も(組み込み機能には無いものの)制御可能。簡単なブロック図であれば Sphinx 上で生成できる。正に、至れり尽くせり。うん、きっとこれはハヤル。(実際にはずっと以前から流行っている)

Sphinx の「ソースを編集 → make → 動作確認」という流れは、昨今のプログラマーには受け入れ難いだろうが、古き良き時代のプログラム開発みたいで老年プログラマーには逆に心地良いはず。私も最初はウキウキしながら make していたが、デザイン調整などで CSS を書き換える度に make するのは流石に面倒になってきた。人間って本当に我がままだと思う。

ということで、(誰もが考え付くであろう)変更を自動で検知して make してみる。

$SPHINX_HOME/bin/makebot.bat:

@echo off
setlocal

:loop
echo [%~n0] watching... press Ctrl-C to abort
python.exe "%~dp0notify-changed.py" .
if errorlevel 1 exit /b 1
echo [%~n0] detected
call make clean && call make html
echo.
goto loop

$SPHINX_HOME/bin/notify-changed.py:

#!python.exe

__doc__ = """\
Usage:
  python %s DIRECTORY
Description:
  Watch and wait for changes in a DIRECTORY and ends with status 0.
""" % __file__

import fnmatch
import os
import sys
import time

_include_patterns = ('*.conf', '*.css', '*.css_t', '*.html', '*.py', '*.rst')
_exclude_patterns = ('_*',)

def _fnmatch_any(name, patterns):
    for pattern in patterns:
        if fnmatch.fnmatch(name, pattern):
            return True
    return False

def _filter_name(names, includes=None, excludes=None):
    if includes:
        names = [x for x in names if _fnmatch_any(x, includes)]
    if excludes:
        names = [x for x in names if not _fnmatch_any(x, excludes)]
    return names

def _filter_dir(names):
    return _filter_name(names, excludes=_exclude_patterns)

def _filter_file(names):
    return _filter_name(names,
                        includes=_include_patterns,
                        excludes=_exclude_patterns)

def _find_files(path):
    for dirpath, dirnames, filenames in os.walk(path):
        dirnames[:] = _filter_dir(dirnames)
        for filename in _filter_file(filenames):
            yield os.path.join(dirpath, filename)

def _iter_filestats(path):
    for filepath in _find_files(path):
        yield filepath, int(os.stat(filepath).st_mtime)

def _diff_filestats(before, after):
    for filepath, mtime in after.iteritems():
        if mtime != before.get(filepath, 0):
            return True
    return False

def _watch_directory(path, interval=1):
    if not os.path.isdir(path):
        return "ERROR: directory not found: `%s'" % path
    before = dict(_iter_filestats(path))
    while True:
        time.sleep(interval)
        after = dict(_iter_filestats(path))
        if _diff_filestats(before, after):
            break
        before = after
    return 0

def main():
    status = 1
    args = sys.argv[1:]
    if args:
        try:
            status = _watch_directory(args[0])
        except KeyboardInterrupt:
            pass
    else:
        print __doc__,
    return status

if __name__ == '__main__':
    sys.exit(main())

本当は pywin32 辺りを使って変更を検知するのがカッコイイのだろうが、生憎 Sphinx には pywin32 が添付されていないので素の Python で実装。数千くらいのファイル数なら問題ないはず(数万とかは知らない)。後は Ctrl-C で終了すると「バッチ ジョブを終了しますか (Y/N)?」とか聞かれるのがウザいが、これは Windows BAT のクソ仕様の問題だし、基本 makebot しっ放しなので我慢する。

取りあえず、これで私の Sphinx on Windows 環境は幸せになった。