読者です 読者をやめる 読者になる 読者になる

今日は$formatと--dataオプションの取り扱いに細かな調整を加えた

github.com

今日は$formatと–dataオプションの取り扱いに細かな調整を加えた。具体的には以下の様な感じ。

  • $formatの対象を文字列だけから拡張した
  • --data でファイル名だけではなく json referenceを取れるようにした

$formatの対象を文字列だけから拡張した

今までは$formatが受け入れるのは文字列だけだった。

one:
  $format: "{prefix}1"
  prefix: no.
no.1

これをdictまたはlistを受け取れるようにした。例えばlistの場合。

items:
  $format:
    - "{prefix}1"
    - "{prefix}2"
    - "{prefix}3"
  prefix: no.
items:
- no.1
- no.2
- no.3

あるいはdictの場合。

items:
  $format:
    one: "{prefix}1"
    two: "{prefix}2"
    three:  "{prefix}3"
  prefix: no.
items:
  one: no.1
  two: no.2
  three: no.3

--data でファイル名だけではなく json referenceを取れるようにした

--data でファイル名だけではなく json referenceを取れるようにした。これにより、細かな設定毎にファイルを分ける必要がなくなった。 また、 --data は複数取れるので、互いに独立な設定を適宜組み合わせる感じのmixinのようなことも出来るようになった。

mixinのようなことというのはこういう感じ。例えば以下のようなconfig.yamlがあるとする。これが --data の対象になる。

version:
  dev:
    prefix: dev
  staging:
    prefix: staging
infra:
  local:
    host: localhost
  cloud:
    host: example.com

ここでversion用の設定とinfra用の設定はそれぞれ独立している。dev-local, dev-cloud, staging-local, staging-cloud の4種類の可能性が存在。 これを適宜組み合わせて作ることが出来る。

例えば、それを確認するために、以下のようなmain.yamlを作る。

# これはdebug用のyaml `--data` の値を確認できるようにするだけのもの
$get: data

それぞれ4つのバリエーションが試せるようになった。

$ zenmai main.yaml --data "config.yaml#/version/dev" --data "config.yaml#/infra/local"
prefix: dev
host: localhost
$ zenmai main.yaml --data "config.yaml#/version/dev" --data "config.yaml#/infra/cloud"
prefix: dev
host: example.com
$ zenmai main.yaml --data "config.yaml#/version/staging" --data "config.yaml#/infra/local"
prefix: staging
host: localhost
$ zenmai main.yaml --data "config.yaml#/version/staging" --data "config.yaml#/infra/cloud"
prefix: staging
host: example.com

今週

今週は大きな機能拡張ではなくこういう感じの細かな調整に時間を使う予定(たぶんきっと)

小さなissueを作ってはcloseを繰り返してた

github.com

細々としたaction(関数)を追加する作業。小さなissueを作ってはcloseを繰り返してた。

例えば以下のようなaction(関数)が追加された

  • counter
  • format
  • get (with default value)

追加されたものたち

counter

$let:
  c0: {$counter: 3}
  c1: {$counter: 0}
body:
  - {$c0: "item{:04}"}
  - {$c1}
  - {$c0: "item{:04}"}
  - {$c1}

output:
  - item0003
  - 0
  - item0004
  - 1

counter的な何かは欲しくなりそうな感じ。例えばアプリの設定のport番号などを割り振る際に利用したくなる。

format

- $format: "{prefix}{number:04}"
  prefix: foo
  number: 0
- $format: "{prefix}{number:04}"
  prefix: foo
  number: 1
- $format: "{prefix}{number:04}"
  prefix: bar
  number: 0

output:
  - foo0000
  - foo0001
  - bar0000

formatもたぶん同様に欲しくなる。特定のprefixと組み合わせたアプリケーション名だったり、アクセス先のホスト名/ドメイン名などを作る機会は結構ありそう。このactionの実装はすごい簡単なので載せておく。

def format(fmt, **kwargs):
    return fmt.format(**kwargs)

かんたん。

get (with default value)

元々$get自体はあったのだけれど、デフォルト値を取る事ができなかったので取れるようにした。dict.getとも対応が取れる様になったので良い名前になった(ただしdefault値を渡さない場合はkeyerrorが出る。Noneではない)。

$let:
  person:
    name: foo
body:
  - {$get: person}
  - {$get: "person#/name"}
  - {$get: "person#/age", default: 0}


output:
  - name: foo
  - foo
  - 0

$importをするのはすごいお手軽

繰り返しになるけれど$importをするのはけっこう手軽。なので組み込みのactionに無いものは自由に作ってimportしてしまえる。例えば、上のformatの例だと。

fmt.py

def format(fmt, **kwargs):
    return fmt.format(**kwargs)

こういうものを書いてあげれば、こういう風に使える。

code:
  - {$from: "./fmt.py", import: format}

items:
  - $format: "{prefix}{number:04}"
    prefix: foo
    number: 0
  - $format: "{prefix}{number:04}"
    prefix: foo
    number: 1
  - $format: "{prefix}{number:04}"
    prefix: bar
    number: 0

importには物理的なファイル名を指定出来るのでけっこう便利だと思う。(文法上の制約でitemsが必要みたいな話はあったり。出力がcodeを使った形状(dict)になっているので)

今後のこと

今週はとりあえず消費者的な立ち位置から足りないものをあれこれと補っていこうかなという感じ。あと、ドキュメントを書かないととは思うもののやる気が不足している。ところであるコンテキスト上のdoctestみたいな物がほしい。

例えば、上のそれぞれのyamlの実行例をサンプルとしてドキュメントとして利用しつつテストにも使いたいみたいな感じ。

現状のテストは以下の様な感じ。

class ActionsTests(DiffTestCase):
    def test_get(self):
        class m:
            from zenmai.actions import get  # NOQA

        source = textwrap.dedent("""
        $let:
          person:
            name: foo
        body:
          - {$get: person}
          - {$get: "person#/name"}
          - {$get: "person#/age", default: 0}
        """)

        d = self._callFUT(source, m)
        actual = loading.dumps(d)
        expected = textwrap.dedent("""
        - name: foo
        - foo
        - 0
        """)
        self.assertDiff(actual.strip(), expected.strip())

出来ないことがあるのは嫌だったので `--data` オプションを追加した。

github.com

今までconfigファイルを受け取ってその値を利用してrenderingするということができなかった。別の言い方をすると静的な参照関係を記述することは出来たけれど、動的な参照関係を記述することができなかった(個別にrootとなるようなファイルを定義しなおせば出来ないわけじゃないけれどつらい)。

とりあえずの対応ではあるけれど、--data オプションを追加した。以下の様な形で使う。

$ zenmai main.yaml --data=config.json
oauth:
  token: foo
  secret: '*secret*'

--dataオプションで渡されたjsonyamlは辞書としてdataという変数に格納される(--data は複数渡せる。複数渡した場合には$concatで結合される)。 なので使う場合には$get経由でアクセスする。main.yamlはこういう感じ。

# need: token, secret
oauth:
  token: {$get: "data#/token"}
  secret: {$get: "data#/secret"}

ネストした値へのアクセスについてどうしようか悩んだのだけれど。json referenceの変形の様な形で対応した(foo["bar"]["boo"]みたいな形式に対応するのがだるいというのと。pythonでは foo.bar.boo と区別されるので.を利用したくはないという気持ちがあった)。

とりあえず設定ファイルをjsonでも渡せるようにしている(拡張子を見てよしなにやってくれている)

{
  "token": "foo",
  "secret": "*secret*"
}

結局面倒くさくなってjinja2のtemplateをサポートした

github.com

はじめに

結局面倒くさくなってjinja2のtemplateをサポートした。なんだかんだでtemplateの機能はすごく便利。とは言えせっかくのdict(yaml)の構造が全て無視され文字列として扱われるというところが微妙といえば微妙。

どうしてtemplateが便利なのかということをちょっと考えてみたけれど。templateを定義できるというのは遠くの位置の場所に穴をあけてそこに後で値を埋め込むというようなことが出来るから便利。

$jinja2_template

例えば、before,afterは決まっていて、bodyだけを書き変えたいような状況。がんばって自分でdictのように扱って埋め込んでも良いのだけれど。やっぱりつらい。

section:
  before:
    <before text>
  body:
    <本文>  # ここだけ差し替えたい
  after:
    <after text>

簡単に実装出来るならと思ってとりあえず実装してみたら瞬殺だったので、加えてみることにした。

上の例に対応するものをこのjinja2のtemplateの機能を使うとこういう感じになる。$jinja2_templateは関数を返す。関数はletで束縛しておくと普通のactionとして使える。(個人的には、束縛した変数はkebab-caseで書かれているのが好きかも。pythonなどでは定義出来ない形式なのでこの言語上で格納している情報ということが分かって便利)

$let:
  template-section:
    $jinja2_template: |
      section:
        before: <before text>
        body: {{body}}
        after: <after text>
body:
  - $template-section: {body: body1}
  - $template-section: {body: body2}

こういう感じ。

$ zenmai main.yaml
- section:
    before: <before text>
    body: body1
    after: <after text>
- section:
    before: <before text>
    body: body2
    after: <after text>

j2cliについて

ちょっとした脱線だけれどj2cliについても考えたい。現状の実装で静的な参照関係には対応しているものの動的な参照関係には対応していないような気がする。やり方は色々あるのだけれど。どのように対応したら良いのかと言うのがまだ見えていない。j2cliの仕組みはすごく単純なので参考になるかもしれない。

j2cliのコマンドのj2の使い方の最も単純化した例は以下の様な感じ。

$ j2 <template> <data>

j2cliはそもそもjinja2というtemplate engineのコマンドライン用のclientでしかないので、jsonyamlだとかの構造は関係ないのだけれど。やっていることは単純でtemplate用のファイルとは別に設定情報的な情報を取るような仕組み。取り込んだ情報をそのままrenderingに使うという感じ。

使い方はこういう感じ

例えば、dev用の情報staging用の情報production用の情報を別々の設定ファイルに入れておき、template部分は共通化するというような使い方。参照関係が複雑なわけではなければこのままでも十分。

とは言え、ちょっとした違いのバリエーションを用意したいときなどに困ることがあったりする。例えば、loggingのlevelだけdebugにしたlocalを作りたいだとか。local用の設定はチームの全体で共有されているが、自分用の設定ファイルには一部の変更を加えたいと言うような状況。このあたりの対応についてどうするかということがまだ正確には見えていない。

そろそろreadme

そろそろreadmeをどうにかしたいというような気持ちが出てきた。なんだか見てみると順を追って説明ができていないような気がする。直したいと思うんだけれど直すための気力が捻出できずにいる。

一時的に今まで存在していたコード用の例と共にコマンドラインのコマンドを使った例を併記していたのだけれど。考えてみるとコード用の例が必要になる人は存在しないのではと思ったりしたので、readmeにはコマンドラインだけの例にした。

(code exampleの方$inc,$inc2で解釈の関係明らかになったりとか、sys.modules[name]渡しているので定義した関数がすぐに利用できるとか、importできるとかは明らかになっているけれど。コード例自体には意味がない。あと、今なら別ファイルを相対パスでもimport出来るので、command line exampleの方の感じで書けば十分みたいなところもあるし。 あと、いきなりdictknife(別のライブラリ)使っているのも例としては微妙)

機能も揃ってきたので真面目な例も考えようと思った結果、禁断の果実(eval)に手を伸ばしてみたり

github.com

はじめに

1週間くらい触ってきた結果それなりに機能が揃ってきたような気がする。$loadで外部のリソースから値を取ってくる事ができるようになったし。その際にjson pointerで指定した範囲の一部分を取り出すことも出来る。$concatで複数の断片をつなぎ合わせて1つにすることが出来るし。何か不足することがあれば$importで直接pythonの関数をimportすることも出来る。scopeも変数束縛も出来るようになった。

そんなわけでもう少し真面目な例を考えてみようと思った。

フィルタリング

真面目な例をということで考えている内に、フィルタリングの操作がしたいことがあるかもしれないと思い始めた。例えば、json referenceによって特定のファイルの特定の箇所のデータを取ってくることは出来るのだけれど。そのデータの内ある特定の条件を満たしたものだけを操作の対象にしたいということがしばしばある気がする。

例えば、以下のようなnums.yamlがありこの内のnums0の方を利用した設定を作りたい。この時奇数だけを取り出したいみたいな状況(通常の利用例では数値が欲しくなることは少なく、何らかの文字列(e.g. ファイル名)のリストを拡張子などで絞り込むみたいな感じかもしれない)。

nums.yaml

definitions:
  nums0:
    [1, 2, 3, 4, 5, 6]
  nums1:
    [1, 2, 3, 5, 7, 11]

この時、$loadを使えばnums0だけを取り出すことは出来る。

main0.yaml

definitions:
  nums: {$load: ./nums.yaml#/definitions/nums0}
$ zenmai main0.yaml
definitions:
  nums:
  - 1
  - 2
  - 3
  - 4
  - 5
  - 6

ただこの内、奇数だけを利用したい箇所、偶数だけを利用したい箇所というのが出てくるかもしれない。

まっとうな方法

importの際にPYTHONPATHに追加せずとも直接物理的なファイル名でimport出来るようにしている。なので以下のようにテキトウに自分でフィルタリングする関数を書いてあげてimportするというのがまっとうな方法かもしれない。

main1.yaml

code:
  $import: ./additional.py
  as: my
definitions:
  $let:
    nums: {$load: ./nums.yaml#/definitions/nums0}
  odds:
      $my.odds: {$my.get: nums}
  even:
      $my.evens: {$my.get: nums}

自分で定義したadditional.pyを読み込んで、myとしてimportしたものを利用する。additional.pyは以下の様な感じ(letで束縛された値を取り出す操作を提供し忘れたのでついでにそのための関数も定義しておく)。

additional.py

from zenmai.decorators import with_context


@with_context
def get(name, context):
    return getattr(context.scope, name)


def odds(nums):
    return [n for n in nums if n % 2 == 1]


def evens(nums):
    return [n for n in nums if n % 2 == 0]

結果は期待通りのもの

$ zenmai main1.yaml
definitions:
  odds:
  - 1
  - 3
  - 5
  even:
  - 2
  - 4
  - 6

ちょっとした怠け者の思考

まじめにpythonの関数を書くのも面倒ということもあるかもしれない?(あるかもしれない)。そんな時にちょっとした横着をする方法が提供されていても良いのかもしれない。危ないけれど。例えばこういう感じで使う。先程と同様の例。

main2.yaml

definitions:
  $let:
    nums: {$load: ./nums.yaml#/definitions/nums0}
  odds:
      $dynamic: |
        [n for n in nums if n % 2 == 1]
  even:
      $dynamic: |
        [n for n in nums if n % 2 == 0]

何だかんだでpythonの式を直接かけたりすると捗るということはある気がする。こういう機能も標準で用意しておくと便利かもしれない。。evalですね。 以下の様な関数を組み込みにしてしまうと上の例が動くようになります。

from zenmai.actions import load  # NOQA
from collections import ChainMap
from zenmai.decorators import with_context


def get_locals(scope, scopes=None):
    scopes = scopes or []
    scopes.append(scope.__dict__)
    if getattr(scope, "parent", None) is not None:
        return get_locals(scope.parent, scopes=scopes)
    else:
        return ChainMap(*scopes)


@with_context
def dynamic(transform, context):
    """teribble dangerous function"""
    local_values = get_locals(context.scope)
    r = eval(transform, {}, local_values)
    return r

組み込みの関数を定義するmoduleを変更したい場合には -m で直接moduleを指定できます(defaultはzenmai.actions)。例えば上のpythonのファイルをfns.pyとすると

$ zenmai -m ./fns.py main2.yaml
definitions:
  odds:
  - 1
  - 3
  - 5
  even:
  - 2
  - 4
  - 6

これはこれでありのような気がする。

$letを実装しました

github.com

昨日話題にだけだしていて未実装だった$letという記法を実装しました。夢物語的な皮算用ではなくなりました。 予想通り以下が必要になりました。

  • scope
  • 関数呼び出しとは異なる評価順序の特殊構文

以下の様なコードが動くようになります。

data.yaml

$let:
  withPlus:
    {$partial: $prefix, prefix: +}
person:
  name: {$withPlus: foo}
  age:
    $let:
      withPlus: {$partial: $add, v: 10}
    body:
      $withPlus: 10
friends:
  - name: {$withPlus: bar}

ここで

$ zenmai -m ./add.py data.yaml
person:
  name: +foo
  age: 20
friends:
- name: +bar

この時以下のようなactionが定義されているとする(add.py)。

from zenmai.actions import partial  # NOQA


def prefix(d, prefix=":"):
    return prefix + d


def add(n, v=1):
    return n + v

以下詳細。

scope

scopeを実装しました。scopeは以下の2つの箇所で新しく生成されます。

  • $loadを呼んだとき
  • $letでscopeを作ったとき

これに伴い、$importや$fromによるimportも現在のscopeに対して束縛するようになりました。

関数呼び出しとは異なる評価順序の特殊構文

今までは、以下のような記述を書いたときには、<d> を評価する前に <val0><val1> が評価されていました。

<fn>: <d>
<kw0>: <val0>
<kw1>: <val1>

一方let-syntaxは以下の形式なので

$let:
  var0: val0
  var1: val1
  ...
body: <d>

通常の評価順序の場合、先にbodyの部分が評価されてしまいます。これは良くありません。仕方がないのでとりあえず今回は$letだけ特別に評価順序を変えてサポートしました。

そろそろ

そろそろ、またドキュメント(readmeやexamplesを含む)が実装に追いつかなくなってきた。あと例を真面目なものにしたい。

yamlのblockスタイルとflowスタイルの違いを楽しんだ

github.com

今日もyaml上の言語のことを考えていたのだけれど。関数(action)呼び出しの部分はflowスタイルと併用すると少し見やすくなるかもしれない。

yamlのblockスタイルとflowスタイルについて

yamlにはblockスタイルとflowスタイルの2つのスタイルがある。それについての雑な説明。

blockスタイルは以下のようなもの。

person:
  name: foo
  age: 1

よく使われているし。全てこれで書くのが正義だと思っていた。一方flowスタイルはこんな感じ。

person: {name: foo, age: 1}

ちなみに、JSONはflowスタイルのsubsetと考える事ができるらしい?(JSONは常にvalidなyamlのflowスタイル)

関数呼び出しについて

例えば、$prefixという渡された文字列の先頭にvalueという引数で渡された文字列を追加するactionがあるとする。

person:
  name:
    $prefix: foo
    value: +
  age:
    $inc: 1

person:
  name: +foo
  age: 2

になる。

この呼出しをblockスタイルで書いたのが上の例の通り。これはこれで良いのだけれど。文字列の様な単純な値を対象とするときには、$prefixの引数として使われるvalueが少し離れてしまっていてぱっと見関連のない値のように見える。これをflowスタイルを取り入れると見た目も関数呼び出しっぽくなって良い。

person:
  name: {$prefix: foo, value: +}
  age: {$inc: 1}

(ちなみに、valueというキーワード引数の名前はどうかと思うのだけれど。prefix,suffixで共通して使える何かを探そうとして失敗した)

変数束縛

ところでactionの対象(ここでは$prefixのfooなどを指している)が短い場合はflowスタイルが良いけれど。長い場合はblockスタイルにならざるおえない。変数束縛の様な機能を追加してみるのも良いかもしれない。色々記法を考えたのだけれど。名前と値のペアということに縛られた上での記法を考える必要があり、なかなかこれというものが思いつかなかった。結局、右往左往した結果、letと言うものを導入することにした(まだ実装はしていない)。こういう形式。

$let:
  <var1>: <value1>
  <var2>: <value2>
  ...
body:
  <body>

var1とvalue1のペアに束縛したい値を渡す。bodyにその環境(scope)での操作を書く。例えば以下の様な部分適用された関数を返す関数(action)の$partial$が定義されていたとして(気持ちとして関数を返す関数なので末尾に$を付けている)。以下の様に書ける。

$let:
  withPlus:
    {$partial$: $prefix, value: +}
body:
  person:
    name: {$withPlus: foo}
    age: {$inc: 1}

対象がオブジェクト(dict)の場合にはbodyを省略できたら嬉しい。

$let:
  withPlus:
    {$partial$: $prefix, value: +}
person:
  name: {$withPlus: foo}
  age: {$inc: 1}

ところで、この$letの実装は少し曲者で、引数部分(body)を評価する前に対象の部分を評価する必要がある。例えば、上の例でいうと、withPlusが束縛されていない状態で、person以下の部分の評価が行われてはマズイ。普通の関数では無理でちょっとした構文として取り扱う必要があるかもしれない。

あと、今現在の実装ではscopeという概念がないので実装する必要があるかもしれない。

scopeの話

scopeの話もちょっとだけしてみる。scopeがないとどういうことが起きるかというと、

$let:
  withPlus: {$partial$: $prefix, value: +}
person:
  name: {$withPlus: foo}
  age:
    $let:
      withPlus: {$partial$: add, n: 10}
    body:
      $withPlus: 10
friends:
  - name: {$withPlus: bar}

ここでfriends部分の$withPlusがどのような値になるかという問題。scopeがない場合には、例えば、addが使われた方のwithPlusが使われるかもしれない。もちろん以下の様な評価が行われてエラーを出す。add('bar', n=10)

あるいは、以前に導入した$loadの扱いについて、

foo:
  $load: bar.yaml
bar:
  $xxx: xxx  # いきなり$xxxと言うものが生える。

$loadによって内部で読み込まれた値がそのまま環境に束縛される場合に、いきなり$xxxという謎の関数が使える様になってしまう。それも$loadした式の後でのみ。例えば以下のような呼び出し関係になっているところで、

$load a.yaml # in main.yaml
  $load b.yaml # in a.yaml
    $load c.yaml # in b.yaml

深い$loadの呼び出しの末端部分で読み込まれた情報が使われると言うようになってはだいぶ困る(global変数)。

再びblockスタイルの話

先程の$letを使った部分、あれを全部blockスタイルで書くとすると以下の様になる。

$let:
  withPlus:
    $partial$: $prefix
    value: +
person:
  name:
    $withPlus: foo
  age:
    $let:
      withPlus:
        $partial$: add
        n: 10
    body:
      $withPlus: 10
friends:
  - name:
    $withPlus: bar

慣れれば分からなくもないけれど。flowスタイルの方が呼び出し時のキーワード引数っぽさが出てるような気がする。

そう言えば

そう言えば、ちょっとだけ機能を追加して、コマンドとして呼べるようにした。以下で動くような感じ。

$ zenmai data.yaml

何か追加でaction定義したい場合には以下の様にすればOK。

foo.py

def add2(n):
    return n + 2

data.yaml

person:
  name: foo
  age: {$add2: 10}
$ zenmai -m ./foo.py ./data.yaml
person:
  name: foo
  age: 12