swagger-marshmallow-codegenでカスタマイズ出来るようにした

swagger-marshmallow-codegenで簡単なカスタマイズ出来るようにした。

例えば以下の様なことができるようになった

  • defaultで使うschema classをMySchemaに変える
  • 特定の条件を満たした値のときには自分で作った独自のfieldを使うように変える

ただこれらはすごくwork-aroundっぽい方針で作っているのであんまり綺麗ではないかもしれない。自分でDriverというクラスを作りそのクラスを --driver に渡す感じで使う。例えば以下の様な形。

$ swagger-marshmallow-codegen --driver=_custom.py:MyDriver --logging=DEBUG person.yaml > person.py

defaultで使うschema classをMySchemaに変える

defaultで使うschema classを変えるには codegen_factory を変える。myschema モジュールのMySchemaが使いたい場合には以下の様にする。

from swagger_marshmallow_codegen.driver import Driver

class MyDriver(Driver):
    codegen_factory = Driver.codegen_factory.override(schema_class_path="myschema:MySchema")

特定の条件を満たした値のときには自分で作った独自のfieldを使うように変える

こちらも同様に dispatcher_factory を変える。

例えば format=objectId のものは自分で定義した myschema の ObjectIdを使うように変えるときには以下の様にする。default値を気にせずmappingを変更する場合には、以下だけで良い。

type_map = {
    Pair(type="string", format="objectId"): "myschema:ObjectId",
    **TYPE_MAP,
}


class MyDriver(Driver):
    codegen_factory = Driver.codegen_factory.override(schema_class_path="myschema:MySchema")
    dispatcher_factory = Driver.dispatcher_factory.override(type_map=type_map)

とは言えdefault値の扱いを考えるとこちらは少し頑張らないとだめ。

from swagger_marshmallow_codegen.driver import Driver
from swagger_marshmallow_codegen.dispatcher import TYPE_MAP, Pair, FormatDispatcher, ReprWrapString


class MyDispatcher(FormatDispatcher):
    type_map = {
        Pair(type="string", format="objectId"): "myschema:ObjectId",
        **TYPE_MAP,
    }

    def dispatch_default(self, c, value, field):
        if isinstance(value, bson.ObjectId) or field.get("format") == "objectId":
            c.import_("bson")
            return ReprWrapString("bson.{!r}".format(bson.ObjectId(value)))
        return super().dispatch_default(c, value, field)


class MyDriver(Driver):
    codegen_factory = Driver.codegen_factory.override(schema_class_path="myschema:MySchema")
    dispatcher_factory = MyDispatcher

実行結果

例えば上で定義したものを使うと。以下のようなyaml

definitions:
  person:
    type: object
    properties:
      id:
        type: string
        format: objectId
        default: 5872bad4c54d2d4e78b34c9d
      name:
        type: string
      age:
        type: integer
    required:
      - name

このようなpythonのコードになる。

# -*- coding:utf-8 -*-
from myschema import (
    MySchema,
    ObjectId
)
from marshmallow import fields
import bson


class Person(MySchema):
    id = ObjectId(missing=lambda: bson.ObjectId('5872bad4c54d2d4e78b34c9d'))
    name = fields.String(required=True)
    age = fields.Integer()

参考

一応、参考にするための example も作った。

補足

ちなみにmyschemaのコードは例えば以下のようなもの

import bson
from marshmallow import Schema, fields


class MySchema(Schema):
    class Meta:
        ordered = True
        strict = True


class ObjectId(fields.String):
    default_error_messages = {
        'invalid_object_id': 'Not a valid bson.ObjectId.',
    }

    def _validated(self, value):
        """Format the value or raise a :exc:`ValidationError` if an error occurs."""
        if value is None:
            return None
        if isinstance(value, bson.ObjectId):
            return value
        try:
            return bson.ObjectId(value)
        except (ValueError, AttributeError):
            self.fail('invalid_object_id')

    def _deserialize(self, value, attr, data):
        return self._validated(value)

    def _serialize(self, value, attr, data):
        if not value:
            return value
        return str(value)

こういうちょっとしたデータの受け渡しどうするんだという話

はじめに

今自分で作っている dictknife というリポジトリについにコマンドを追加してしまった。 色々あるのだけれど。今回は dictknife transform の話。

transform

何かしらの形状の変換をしたいことがある。

例えば、こういう入力を受け取って、

properties:
  name:
    type: string
    description: name of something
  age:
    type: integer
    minimum: 0

こういう出力を返したい。

definitions:
  person:
    properties:
      name:
        type: string
        description: name of something
      age:
        type: integer
        minimum: 0

結局、load,dumpを無視するとコード自体は以下だけなのだけれど。

def transform(d):
    return {"definisions": {"person": d}}

これを省力な形で提供するのがちょっとだけ面倒。

面倒くさい点

面倒くさい点は2つある

  • transform 関数の取得
  • transform 関数へ引数を渡したい場合の方法

transform 関数の取得

上の方法で考えた変換(definisions.personでwrapするもの)がもし仮にどこかのpackageで提供されているとする。 すると以下の様に書ける気がする。

package pathを指定する場合

例えば、 foo.bar.transform:lifting で提供されている場合は以下の様に書ける。

$ dictknife transform --function foo.bar.transform:lifting ...

でも、これはちょっと使いづらい。そもそも何度も使って便利だと分かっているものでなければpackageになっていることが少ない。テキトウにファイルを置いてPYTHONPATHを追加するという方法でできなくもないけれど。やっぱり面倒。

$ PYTHONPATH=../myscript dictknife transform --function transform:lifinting ...

package path or 物理的な pathで指定する場合

直接ファイルを指定出来るようになれば十分か?一応、昔作ったmagicalimport というpackageを使うとそれは出来る。例えば上の例は以下のように書けるように出来る。

$ dictknife transform --function ../myscript/transform.py:lifting ...

eval的な何か

しかし、それでも使いにくい。何かしらのちょっとした処理を行いたいときには、一時的なファイルすら作りたくない場合がある。(というよりも、temporaryなscriptや関数群の置き場を決めるという意思決定がしたくないというような状況)。仕方が無いので禁断の果実であるevalを使うことにする。

$ dictknife transform --code 'lambda d: {"definitions": {"person": d}}' ...

transform 関数へ引数を渡したい場合の方法

trasnform 関数が取得できれば万事OKという訳でもない。冒頭の変換について考えてみても、常に "person" という固定の名前で変換したいという状況はあんまりない。どうにかしてtransform 関数へ情報を受け渡したい(そもそもtransformは関数だけで十分なのかという話もあるけれど。あんまり複雑なことを考えたくはないので今回は関数で良いということにしてみる)。

コマンドライン引数で渡す方法

コマンドライン引数で渡す方法はすごく分かりやすい。個別にコマンドを作るということを念頭に置くならこの形が最適かもしれない。とは言え、これを汎用的に提供する機能を作ろうとすると、もはやtransform コマンドのジェネレーターのようなものを作る事になってしまう。

$ dictknife transform --name person --code 'lambda d, name="NAME": {"definisions": {name: d}}' ...

configファイルで渡す方法

汎用的なtransformということを考えると以下の様な関数を作る事にならざる負えない。

def lifting(data, **kwargs):
    ...

pythonに限って言えばキーワード引数になっている方がべんりかもしれない。

def lifting(data, name="NAME"):
    ...

幸い functools.partial に辞書を渡してあげるとキーワード引数を埋める事ができる。

from functools import partial


fn = partial(lifting, **{"name": "person"})
fn(data)  # transform!!

dictを受け取って**で展開してあげれば良いかもしれない。dictを取得する方法を考えてみる。 幸い元々JSONYAMLを入出力するライブラリ上のコマンドなのでconfig用の情報をこれらのフォーマットで受け取るという形で考えても良いかもしれない。

$ dictknife transform --function "./myscript.py:lifting" --config-file ./config.json ...

とは言え、これは transform 関数を作ったときと同じ状況に陥る。本当に単純な処理に関してはファイルなんて作りたくない。

JSONで受け取れる引数を追加する

基本的にはシェル上のコマンドとJSONを直接扱う方法と言うのはあまり良い方法とは思えないのだけれど。jo やその類型のものを使えば幾分かマシになるだろうということで。JOSNを直接受け取れるようにする。

$ dictknife transform --code 'lambda d,name="foo": {"definisions": {name: d}}' --config '{"name": "person"}' ...

一応ワンライナーで済ませる事が出来るようになった。

その他細々としたこと

パイプで繋げられるようにしたい。パイプで繋げられるようなインターフェイスと言うのは以下のようなもの

$ cat src.yaml | dictknife transform <> > dst.yaml 

とは言え、明示的に入出力を指定したい場合もある(go generateで使うときなど)。

$ dictknife transform --src src.yaml --dst dst.yaml <>

現在の状態

現在の状態は以下のようなもの。真面目にdescriptionは書いていないですね。。

$ dictknife transform
Usage: dictknife transform [OPTIONS]

  transform dict

Options:
  --src PATH
  --dst PATH
  --config TEXT
  --config-file PATH
  --code TEXT
  --function TEXT
  --help              Show this message and exit.
  • --src 入力ファイル
  • --dst 出力ファイル
  • --config transform関数に渡すdictのリテラル的な文字列を受け取る
  • --config-file transform関数に渡すdictのファイル(configのファイルversion)
  • --code transform関数のワンラインナーを書きたいときに使う(eval)
  • --function transform関数

追記:

環境変数で設定するみたいな方法もあるのかもしれない?とは言えネストした構造がつらそう。

追記:

JSONを文字列で受け取れるような構造は jq で取り出す形にすると相性が良いかもしれない。