urakataというscaffold command serverのようなものを作ろうとした話

urakataというものを作ろうとした話

OCamlをいじっていて何回同じようなMakefileを作っているんだろうというような思いが浮かんで。 scaffoldのようなコマンドを作ろうかと思ったのですが。scaffoldコマンドの管理がめんどくさい。 元となるtemplateのようなファイル構造が必要になるのだけれど。それをどこに置くかというので悩んでしまっていた。 何か良い方法はないかなと考えた結果。

scaffoldがworkerになってくれると嬉しい

最終的にはscaffoldがworkerになってくれると嬉しい。例えば、jsonをpostするとそれに従ったscaffoldを返してくれるような何か。 と言っても、現実的かというとそうでもないような気がするので以下のような形にすることにした。

  • ファイル構造をscanするとjsonを生成するコマンドを実行
  • 生成したjsonをserverにpost(登録)
  • 登録されたデータを元にscaffoldコマンドを生成

現状のステータス

web appを作ろうかとpyramidで作り始めたけれど。結局コマンドだけ実装するところで終わった。 一応、雛型のようなファイル構造からpythonスクリプトを生成するところまでは動く。

以下の様な形で使える。

$ urakata initialize development.ini
# ファイルを調べてjsonを作成(これがclient側でもできないと本当はマズイ)
$ urakata scan development.ini demo/season -overrides=demo/overrides.season.json > season.json
# dbにjsonのデータを登録
$ urakata register development.ini season.json
# scaffoldの生成
$ urakata codegen development.ini my-scaffold > scaffold.py

$ python scaffold.py season
autumn(default:):?
aki
month(default:):?
gatsu
INFO:__main__:emit[D] -- season
INFO:__main__:emit[F] -- season/aki.txt
spring(default:haru):?

INFO:__main__:emit[F] -- season/haru.txt
summer(default:natsu):?

INFO:__main__:emit[F] -- season/natsu.txt
winter(default:):?
huyu
INFO:__main__:emit[F] -- season/huyu.txt

$ tree /tmp/season
/tmp/season
├── aki.txt
├── haru.txt
├── huyu.txt
└── natsu.txt

0 directories, 4 files

$ cat /tmp/season/aki.txt

aki
- 9gatsu
- 10gatsu
- 11gatsu

不足しているもの

不足しているものは結構あって、web appとしての機能がまるまる無い。 後、本当は登録されているjsonデータから元となるファイル構造を取り出す処理というのも必要。 また、クライアントのスクリプト側でファイル構造を走査してjsonを作成する処理が実行できる必要がある。

appendix

それぞれ生成されるのは以下のようなファイル

// overrides.season.json (これは手書き)
{
  "spring": "春",
  "summer": "夏"
}
// season.json

{
  "usages": {}, 
  "name": "my-scaffold", 
  "root": "demo/season", 
  "defaults": {
    "spring": "春", 
    "winter": "", 
    "month": "", 
    "summer": "夏", 
    "autumn": ""
  }, 
  "templates": [
    {
      "encoding": "utf-8", 
      "name": "+autumn+.txt.tmpl", 
      "content": "{{autumn}}\n- 9{{month}}\n- 10{{month}}\n- 11{{month}}\n"
    }, 
    {
      "encoding": "utf-8", 
      "name": "+spring+.txt.tmpl", 
      "content": "{{spring}}\n- 3{{month}}\n- 4{{month}}\n- 5{{month}}\n\n"
    }, 
    {
      "encoding": "utf-8", 
      "name": "+summer+.txt.tmpl", 
      "content": "{{summer}}\n- 6{{month}}\n- 7{{month}}\n- 8{{month}}\n\n"
    }, 
    {
      "encoding": "utf-8", 
      "name": "+winter+.txt.tmpl", 
      "content": "{{winter}}\n- 12{{month}}\n- 1{{month}}\n- 2{{month}}\n"
    }
  ], 
  "version": "0.0.1", 
  "parameters": [
    "spring", 
    "summer", 
    "month", 
    "autumn", 
    "winter"
  ]
}
import sys
import os.path
import re
from collections import(
    defaultdict,
    Mapping,
    OrderedDict
)
import logging
logger = logging.getLogger(__name__)


class reify(object):
    """ Use as a class method decorator.  It operates almost exactly like the
    Python ``@property`` decorator, but it puts the result of the method it
    decorates into the instance dict after the first call, effectively
    replacing the function it decorates with an instance variable.  It is, in
    Python parlance, a non-data descriptor.  An example:

    .. code-block:: python

       class Foo(object):
           @reify
           def jammy(self):
               print('jammy called')
               return 1

    And usage of Foo:

    >>> f = Foo()
    >>> v = f.jammy
    'jammy called'
    >>> print(v)
    1
    >>> f.jammy
    1
    >>> # jammy func not called the second time; it replaced itself with 1
    """
    def __init__(self, wrapped):
        self.wrapped = wrapped
        try:
            self.__doc__ = wrapped.__doc__
        except: # pragma: no cover
            pass

    def __get__(self, inst, objtype=None):
        if inst is None:
            return self
        val = self.wrapped(inst)
        setattr(inst, self.wrapped.__name__, val)
        return val

class InputWrapper(object):
    def __init__(self, input_port):
        self.input_port = input_port

    def __getattr__(self, k):
        return getattr(self.input_port, k)

    def read(self):
        return input()

class Emitter(object):
    def __init__(self, request, root, config, overrides=None):
        self.request = request
        self.root = root
        self.config = config
        self.overrides = overrides or {}

    @reify
    def env(self):
        return EmitEnv(self.overrides, self.config.defaults, self.config.usages)

    def emit(self):
        for name, content in self.config.contents.items():
            self.emit_content(name, content)

    def emit_content(self, name, content):
        name_scanner = self.config.name_scanner
        template_scanner = self.config.template_scanner

        emit_name = name_scanner.replace(name, self.env)
        if template_scanner.is_template_name(name):
            content = template_scanner.replace(content, self.env)
            emit_name = template_scanner.normalize_name(emit_name)
        fullpath = os.path.join(self.root, emit_name)
        dirpath = os.path.dirname(fullpath)

        if not os.path.exists(dirpath):
            logger.info("emit[D] -- %s", dirpath)
            os.makedirs(dirpath)

        logger.info("emit[F] -- %s", fullpath)
        with open(fullpath, "w") as wf:
            wf.write(content)

class EmitEnv(Mapping):
    def __init__(self, cache, defaults, usages,
                 input_port=InputWrapper(sys.stdin), output_port=sys.stderr):
        self.cache = cache
        self.defaults = defaults
        self.usages = usages
        self.input_port = input_port
        self.output_port = output_port

    def __getitem__(self, k):
        try:
            return self.cache[k]
        except:
            self.cache[k] = self.read(k)
            return self.cache[k]

    def read(self, k):
        usage = self.usages.get(k) or "{name}(default:{default}):?\n".format(name=k, default=self.defaults.get(k, ""))
        while True:
            self.output_port.write(usage)
            self.output_port.flush()
            value = self.input_port.read().rstrip() or self.defaults.get(k)
            if value:
                return value

    def __iter__(self):
        return iter(self.cache)

    def __len__(self):
        return len(self.cache)

class ScanConfig(object):
    def __init__(self, request, root, defaults=None, usages=None, contents=[]):
        self.request = request
        self.root = root
        self.parameters = set()
        self.defaults = defaults or defaultdict(str)
        self.usages = usages or defaultdict(str)
        self.contents = OrderedDict(contents) or OrderedDict()  # name -> content

    def add_usage(self, name, usage):
        self.usages[name] = usage

    def add_default(self, name, default):
        self.defaults[name] = default

    def add_content(self, name, content):
        self.contents[name] = content

    def fill_defaults(self, v=""):
        for p in self.parameters:
            if p not in self.defaults:
                self.add_default(p, v)

    @reify
    def name_scanner(self):
        return NameScanner(self)

    @reify
    def template_scanner(self):
        return Jinja2Scanner(self)

class NameScanner(object):
    def __init__(self, config):
        self.config = config

    rx = re.compile("\+([^\+]+)\+")

    def scan(self, filename):
        for k in self.rx.findall(filename):
            self.config.parameters.add(k)

    def replace(self, name, env):
        def repl(m):
            return env[m.group(1)]
        return self.rx.sub(repl, name)

class Jinja2Scanner(object):
    def __init__(self, config):
        self.config = config

    @reify
    def environment(self):
        from jinja2.environment import Environment
        return Environment()  # todo: input encoding, customize

    def is_template_name(self, name):
        return name.endswith(".tmpl")

    def normalize_name(self, name):
        return name.rsplit(".tmpl", 1)[0]

    def parse(self, content):
        from jinja2 import meta
        ast = self.environment.parse(content)
        return meta.find_undeclared_variables(ast)

    def scan(self, io):
        content = io.read()
        for k in self.parse(content):
            self.config.parameters.add(k)

    def replace(self, content, env):
        from jinja2 import Template
        from jinja2.utils import concat
        t = Template(content)
        return concat(t.root_render_func(t.new_context(env, shared=True)))

def main(args):
    root = args[0]
    defaults = {
      "spring": "春", 
      "autumn": "", 
      "month": "", 
      "summer": "夏", 
      "winter": ""
    }
    usages = {}
    contents = [
      [
        "+autumn+.txt.tmpl", 
        "{{autumn}}\n- 9{{month}}\n- 10{{month}}\n- 11{{month}}\n\n"
      ], 
      [
        "+spring+.txt.tmpl", 
        "{{spring}}\n- 3{{month}}\n- 4{{month}}\n- 5{{month}}\n\n"
      ], 
      [
        "+summer+.txt.tmpl", 
        "{{summer}}\n- 6{{month}}\n- 7{{month}}\n- 8{{month}}\n\n"
      ], 
      [
        "+winter+.txt.tmpl", 
        "{{winter}}\n- 12{{month}}\n- 1{{month}}\n- 2{{month}}\n\n"
      ]
    ]
    config = ScanConfig(None, root, defaults=defaults, usages=usages, contents=contents)
    emitter = Emitter(None, root, config)
    emitter.emit()


if __name__ == '__main__':
    logging.basicConfig(level=logging.INFO)
    main(sys.argv[1:])