cookiecutterのプロジェクトテンプレート上でsnakecase,kebabcase,camelcaseの変換のjinja2フィルターが使いたくなった話

cookiecutterのプロジェクトテンプレート上でsnakecase,kebabcase,camelcaseの変換のjinja2フィルターが使いたくなった。

cookiecutter?

scaffold用のツール。たぶん今の所python界隈で一番メジャー。

github.com

このcookiecutterのscaffoldの最中で良い感じに自分独自のjinja2フィルターなどを使いたい。

snakecase, kebabcase, camelcase

snakecase, kebabcase, camelcaseはjinja2のフィルター。

こういう感じで利用する。

# "foo_bar_boo" になって欲しい
{{"fooBarBoo"|snakecase}}

この記事中での依存関係は、kamidanaもcookiecutterもjinja2に依存しているということだけわかっていれば大丈夫。

jinja2's filter?

フィルターの組み込みや実装自体の方法は以下のjinja2自体のドキュメントに書いてある。

ざっくり言うと、jinja2.Environmentのfiltersやglobalsといったフィールドに良い感じに辞書を与えてあげれば良い。

kamidana.additionals.naming?

github.com

そういう便利フィルターなどを手軽に利用可能にするadditional modulesというコンセプトがある(単に自分がそう呼んでいるだけだけれど)。自作したkamidanaには組み込みのadditional modulesがある。

良い感じに名前に対する変換をシュッとやってくれるフィルターがsnakecase, kebabcase, camelcase。なので以下の様な形で使える。

name.j2

- fooBarBoo|snakecase -> {{"fooBarBoo"|snakecase}}
- fooBarBoo|kebabcase -> {{"fooBarBoo"|kebabcase}}
- foo_bar_boo|camelcase -> {{"foo_bar_boo"|camelcase}}

変換後。

$ kamidana -a naming name.j2
- fooBarBoo|snakecase -> foo_bar_boo
- fooBarBoo|kebabcase -> foo-bar-boo
- foo_bar_boo|camelcase -> fooBarBoo

この機能をcookiecutterでも使いたい。

cookiecutter template

そもそもcookiecutterの利用方法がわからないと利用イメージがつかなそうな気がする。なので手軽にcookiecutterの使いかたの例も紹介する(hello worldレベル)

詳しいことが知りたかったり他の説明を読みたい場合には、一番分かりやすそうな説明はこのあたり。

一番くわしい説明はこのあたり

かも。

プロジェクトテンプレートの使いかた

まず前提として、今回の例は以下のような構造。

$ tree hello 00out/
hello
├── {{cookiecutter.directory_name}}
│   └── {{cookiecutter.file_name}}.py
└── cookiecutter.json
00out/
└── Hello
    └── Howdy.py
2 directories, 3 files

helloというプロジェクトテンプレートから出力したときの生成結果が00out。そしてこれを生成するときのコマンドは以下。(本来はconfirmで一個一個質問が問いかけられて、問いかけられた結果が使われるが --no-inputを付けているのでデフォルトの値がそのまま利用されている。この値自体をJSONで渡すことも可能)

$ rm -rf 00out
$ cookiecutter -v hello --no-input -o 00out
DEBUG cookiecutter.main: context_file is hello/cookiecutter.json
DEBUG cookiecutter.generate: Context generated is OrderedDict([('cookiecutter', OrderedDict([('directory_name', 'Hello'), ('file_name', 'Howdy'), ('greeting_recipient', 'Julie')]))])
DEBUG cookiecutter.utils: Making sure path exists: $HOME/.cookiecutter_replay/
DEBUG cookiecutter.find: Searching hello for the project template.
DEBUG cookiecutter.find: The project template appears to be hello/{{cookiecutter.directory_name}}
DEBUG cookiecutter.generate: Generating project from hello/{{cookiecutter.directory_name}}...
DEBUG cookiecutter.generate: Rendered dir 00out/Hello must exist in output_dir 00out
DEBUG cookiecutter.utils: Making sure path exists: 00out/Hello
DEBUG cookiecutter.utils: Created directory at: 00out/Hello
DEBUG cookiecutter.generate: Project directory is $HOME/venvs/my/individual-sandbox/daily/20190612/example_cookiecutter/00out/Hello
DEBUG cookiecutter.hooks: hooks_dir is $HOME/venvs/my/individual-sandbox/daily/20190612/example_cookiecutter/hello/hooks
DEBUG cookiecutter.hooks: No hooks/ dir in template_dir
DEBUG cookiecutter.hooks: No pre_gen_project hook found
DEBUG cookiecutter.generate: Processing file {{cookiecutter.file_name}}.py
DEBUG cookiecutter.generate: Created file at $HOME/venvs/my/individual-sandbox/daily/20190612/example_cookiecutter/00out/Hello/Howdy.py
DEBUG cookiecutter.generate: Check {{cookiecutter.file_name}}.py to see if it's a binary
DEBUG cookiecutter.generate: Writing contents to file $HOME/venvs/my/individual-sandbox/daily/20190612/example_cookiecutter/00out/Hello/Howdy.py
DEBUG cookiecutter.hooks: hooks_dir is $HOME/venvs/my/individual-sandbox/daily/20190612/example_cookiecutter/hello/hooks
DEBUG cookiecutter.hooks: No hooks/ dir in template_dir
DEBUG cookiecutter.hooks: No post_gen_project hook found

ちなみにrm -rf 00outしているけれど、単にoverwriteしたいだけなら-fオプションを付けるだけでも良い。あとどのような機能が裏で動いているかわかりやすいように-vオプションを付けている。

雑に言えば、以下の2つから良い感じに生成してくれるというようなもの。

  • プロジェクトテンプレート
  • 生成に必要な設定

詳しくはドキュメントを。

プロジェクトテンプレートの作り方

雛形となるディレクトリを作る。ディレクトリ名やファイル名にもjinja2テンプレートの記法が使えるところが特徴。helloを再掲。

$ tree hello
hello
├── cookiecutter.json
└── {{cookiecutter.directory_name}}
    └── {{cookiecutter.file_name}}.py

1 directory, 2 files

ポイントは cookiecutter.json{{cookiecutter.file_name}}.py 。後者は出力後Howdy.pyになる。前者はデフォルトの設定として扱われる。

cookiecutter.json

{
  "directory_name": "Hello",
  "file_name": "Howdy",
  "greeting_recipient": "Julie"
}

それぞれ保存された名前は cookiecutter. のprefix付きで利用できる。ここでHello.pyは以下のようなもの。

{{cookiecutter.file_name}}.py

print("Hello, {{cookiecutter.greeting_recipient}}!")

つまりディレクトリも含めてjinja2テンプレート。テンプレートに渡すパラメーターを対話によって決める(あるいはJSONファイルとして渡す)。というのがcookkiecutterのプロジェクトテンプレートということになる。

プロジェクトテンプレートでsnakecase,kebabcase,camelcaseがしたい

というところまでが説明のための準備でようやく本題。db名だったりファイル名だったりである特定の部分ではkebabcaseで、基本snakecase。JSとのやり取りの部分はcamelcaseなどということはまぁふつうにある。それをやりたい。

さて、cookiecutterのプロジェクトテンプレートにこれらの機能を含んだもの渡したいということになるがその方法がなかなか見つからない。どうもExtra Contextの機能は期待したものとは異なりprimitiveな値(JSONで表現できるような値)程度しか送ることができない模様。一番無難なのはjinja2のExtensionを利用する方法

いろいろissuesやPRを雑に見て回った所、custom filters関係の議論で一番参考になりそうなのはこのPR(Openのまま)。

エンドユーザーとしてはjinja2 Extensionでやりましょう説が濃厚。

cookiecutterでjinja2のExtensionの利用

大雑把に言うと cookiecutter.json 中に _extensions というフィールドを設けてあげると、jinja2.utils.import_string() で読み込んでくれるらしい。

たとえばこういう形で jinja2_time のExtensionが使えるようになる。

github.com


{
    "project_slug": "Foobar",
    "year": "{% now 'utc', '%Y' %}",
    "_extensions": ["jinja2_time.TimeExtension"]
}

jinja2のExtensionの定義

jinja2のExtensionの定義自体はjinja2のドキュメントに書いてある。

これを使って良い感じに、kamidanaのadditional modulesをforwardingしてあげるようなExtensionを用意すれば良い。

実際の組み込み

kamidana側にテキトーなissueを作った(英語もテキトー :bow:)

前回の記事で触れた --list-info の機能が便利なのでここで紹介。何やら表示が色々増えましたねという感じなのだけれど。注目して欲しいところは以下2つ

  • kamidana.extensions.NamingModuleExtension
  • kamidana.exttensions.CookiecutterAdditionalModulesExttension
$ kamidana --list-info
extensions are used by `-e`, additional modules are used by `-a`.
{
  "extensions": {
...
the Jinja template",
    "kamidana.extensions.NamingModuleExtension": "extension create from kamidana.additionals.naming",
...
    "kamidana.extensions.CookiecutterAdditionalModulesExtension": "activate additional modules, see context['cookiecutter']['_additional_modules'], created from your cookiecutter.json"
  },
  "additional_modules": {
...
    "kamidana.additionals.naming": "Naming helpers (e.g. snakecase, kebabcase, ... pluralize, singularize)",
...
  }
}

NamingModuleExtension

kamidana.extensions.NamingModuleExtension はそのままkebabcase,snakecaseなどが使えるようになるだけの穏やかな実装。なので単にそれらが使いたいだけならこれを使うと良い。

もちろん、cookiecutter.jsonに以下の様な記述が増えることになる。

{
...
  "_extensions": ["kamidana.extensions.NamingModuleExtension"]
...
}

ただし、このように必要な機能の毎にExtensionを追加していく方針では、必ずExtensionを作らなくてはいけない。そして必ずpython packageとしてimport可能な場所に置いておかなければいけない。という注意事項が付いてくる。

元々のkamidanaのコンセプトとして「その場での定義と利用を尊重する」ということが念頭にあったのでこれは受け入れられない(例えば kamidana -a ./my_module.py <template> などとすると自分で書いた関数がadditional modulesとして読み込まれる)。

CookiecutterAdditionalModulesExttension

kamidana.exttensions.CookiecutterAdditionalModulesExttension はわりとやばめの機能。とはいえkamidanaのコンセプトを尊重するのなら必要な機能ということで頑張った。実装自体もコールスタックを覗くというちょっとした黒魔術が入っている。

使いかたは _extensions の他に _additional_modules というフィールドを用意した形。なのでkebabcaseなどをこのExtension経由で使いたい場合にはcookiecutter.jsonに以下の様な設定が追加される。

{
...
  "_extensions": ["kamidana.extensions.CookiecutterAdditionalModulesExttension"],
  "_additional_modules": ["kamidana.additionals.naming"]
...
}

実際の利用例は examples が参考になるかもしれない。

↑のexamplesでは以下のようなcookiecutter.jsonが使われている。

{
  "directory_name": "Hello",
  "project_name": "defaultProject",
  "_extensions": [
    "kamidana.extensions.CookiecutterAdditionalModulesExtension"
  ],
  "_additional_modules": [
    "./about_kamidana.py",
    "kamidana.additionals.naming"
  ]
}

ここで ./about_kamidana.py は以下のようなもの。

from kamidana import as_global
from kamidana.compat import importlib_resources


@as_global
def about_kamidana():
    return importlib_resources.read_text("kamidana", "data.txt").rstrip()

そしてtemplate中では以下のような形で使われている。

# use the feature of builtin additional modules
original = "{{cookiecutter.project_name}}"
print("camelcase: {{cookiecutter.project_name|camelcase}}")
print("snakecase: {{cookiecutter.project_name|snakecase}}")
print("kebabcase: {{cookiecutter.project_name|kebabcase}}")


# use the feature of individial additional modules
"""
{{about_kamidana()}}
"""

出力結果は以下のようなもの。

# use the feature of builtin additional modules
original = "defaultProject"
print("camelcase: defaultProject")
print("snakecase: default_project")
print("kebabcase: default-project")


# use the feature of individial additional modules
"""
# for testing
jinja is 神社
kamidana is 神棚
"""

おしまい。

ちなみに、コードが壊れていないか担保するCIの設定も実はちょっとおもしろいので参考になる人もいるかもしれない。git diffでの差分がないことをokとする(ビジュアル?)リグレッションテスト。

(ちなみにcookiecutterの標準の機能としてcustom filterなどの機能が入って欲しいという思いはあります :pray: )