新年最初のコードはalchemyjsonschemaを1年ぶり位に弄ることだった

新年最初のコードはalchemyjsonschemaを1年ぶり位に弄ることだった。

alchemyjsonschema

sqlalchemyのmodelの定義から対応する感じのjsonschemaを生成するコマンド(ライブラリ)。去年くらいにjsonschemaではなくswaggerをdefaultにした。

やったこと

やったことは以下

  • formatオプションでjsonyamlの出力を分けられるようにした
  • layoutオプションでswagger2.0以外の表現で出力できるようにした

これに付随して以下の作業をした。結構依存した作業が多かった

  • alchemyjsonschemaでもdictknifeを使うようにした
  • alchemyjsonschemaでもmagicalimportを使うようにした
  • ciの適用範囲を3.5だけから3.4,3.5,3.6,3.6-dev,nightlyに変更した
  • dictknifeでjson,yamlを出力する時に--sort-keysオプションを使えるようにした。

dictkinfeを使うようにした

もともとalchemyjsonschemaでは出力がjsonに限定されていた。去年の9月頃に--swaggerとか指定したときにyamlになっていると嬉しいみたいなissueが作られていて反応していなかったのだけれど。まぁ暇つぶしにやってみるかということでサポートする気になった。

とは言えswaggerならという表現はひどく曖昧で(そもそもOAS2.0とOAS3.0どっちを指すんだとか。現在は過渡期なのだけれど。多くのツールがまだ2.0だとか)それを指定するよりは明示的にファイルフォーマットを指定できた方が良いだろということでformatオプションを作るようにした。

# defaultはformat=json
$ alchemyjsonschema models.py
# yamlが欲しい場合にはformat=yaml
$ alchemyjsonschema models.py --format=yaml

この対応はdictknifeを使うとけっこう手軽にできる。dictknifeは個人的に作っているライブラリで色々な自作のツールがこれに依存し始めてしまっている(便利なのだけれど代替品があるならそちらを使いたいという気持ちもある)。

良いところは2つあって。

  • 出力の形式をformatオプションで受け取れる様になっていること
  • 「標準出力あるいはファイル出力」みたいな処理の分岐のコードが不要になること

出力の形式をformatオプションで受け取れる様になっていること

1つは出力の形式をformatオプションで受け取れる様になっていること(加えて一方を指定した場合にもう一方はimportされない(例えばjsonを指定して実行したときにyaml用の依存ライブラリを無駄にimportしない))。

from dictknife import loading

d = {"name": "foo"}

with open("person.json", "w") as wf:
    loading.dump(d, wf, format="json")

# 実は拡張子を見るので以下でもOK
with open("person.yaml", "w") as wf:
    loading.dump(d, wf)

defaultはyamlなのだけれど。loading.setupで変えられる。

# jsonをdefaultに
loading.setup(loading.json.load, loading.json.dump)

「標準出力あるいはファイル出力」みたいな処理の分岐のコードが不要になること

もう1つの良いところは「標準出力あるいはファイル出力」みたいな処理の分岐のコードが不要になっているところ。元々のalchemyjsonschemaのコードでもオプションに--outを指定するとファイル出力。ない場合には標準出力に出力という形になっていたのだけれど。

# output to stdout
$ alchemyjsonschema models.py
# output to schema.json
$ alchemyjsonschema --out models.py

このコードに対応するために以下のような分岐があった。

    if args.out:
        with open(args.target, "w") as wf:
            return driver.run(args.target, wf)
    else:
        return driver.run(args.target, sys.stdout)

これが地味にだるいのだけれど。最近の自分のコードではdictknifeを使って以下の様に書ける。

    driver.run(args.target, args.out)
    # 内部的には loading.dumpfile(args.target, args.out) みたいな形になっている

渡されたportがNoneのときには標準出力に出力するというコードになっているので。argparseでdefaultを指定しておくと良い感じ。

import argparse
from dictknife import loading

parser = argparse.ArgumentParser()
parser.addArgument("--out", default=None)
parser.addArgument("--format", default=None, choices=["json", "yaml"])
args = parser.parse_args()

d = {"name": "foo"}

loading.dumpfile(d, args.out, format=args.format)

dictknifeにsort_keysオプションを追加した

ただこの過程で1つ問題を見つけてしまった。今までdictknifeでは現在の状態をそのまま出力するということでjson.dumpsのsort_keys的なオプションを用意していなかったのだけれど。今回のalchemyjsonschemaの変更で欲しくなってしまった。実際sortして出力ができないとコードを実行する度にfieldの順序が変わるので微妙になってしまう。

そんなわけで、dump,dumps,dumpfileにsort_keysというオプションを用意した。ついでにyamlでも同様にsortされるようにした。

# 毎回おんなじ出力になる
d = {"name": "foo", "age": 20}
loading.dumpfile(d, args.out, format=args.format, sort_keys=True)

magicalimportを使うようにした

PYTHONPATHに含まれていないファイルに物理的なファイルパスを指定してロードする仕組みにmagicalimportというライブラリをよく使っている。これも自作のライブラリなのだけれど。

alchemyjsonschemaは過去に作ったライブラリで、このmagicalimportを使っていなかった。pkg_resourcesを直接importしていた。これを止めた。(そういえば、pkg_resourcesを利用するとひどくimportに時間が掛かるという話しを昔に書いた。詳しくはpip install -e でインストールしたpython製のコマンドの起動が異様に遅かった話参照)

この2つの依存が増えたのとpython2.7はサポートされなくなったかも(たぶん)みたいな変更があった。

ciの適用範囲を3.5だけから3.4,3.5,3.6,3.6-dev,nightlyに変更した

これはそのまま。

diff --git a/.travis.yml b/.travis.yml
index 39c5322..e7138f6 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,6 +1,11 @@
 language: python
 sudo: false
-python: 3.5
+python:
+  - "3.4"
+  - "3.5"
+  - "3.6"
+  - "3.6-dev" # 3.6 development branch
+  - "nightly"
 install:
   - pip install -e .[testing]
 script: python setup.py test

ただこの対応は微妙な気がしていて。pythonの新しいバージョンが出るたびにサポートするバージョン変える作業を毎回発生してしまうのがなんかもう少し良い方法無いかな〜と思ったりしている。

小さめのリポジトリをたくさん作る系の生態の人は辛いのではという気持ちになっている。

swagger2.0以外の出力形式の対応

去年に変更してalchemyjsonschemaをコマンドとして利用する場合にはswagger2.0(OpenAPISpec 2.0)で出力するという形だけで良いかなと思ったのだけれど。formatで分岐するようにしたついでにOpenAPI3.0にも対応するかという気になった。

と言ってもschema部分に関してはそんなに大変ではなく。#/definitions以下に置かれていた定義を#/components/schemas以下に置かれるようにするだけ。以下3つのlayoutを指定できるようにする(formatはファイルフォーマットとかぶるし。他に良い名前を思いつかなかったのでとりあえずlayout)。

# defaultはopenapi2.0
$ alchemyjsonschema models.py --format=yaml

# jsonschemaはクラスまで指定しないとダメ
$ alchemyjsonschema --layout=jsonschema models.py:User

# openapi3.0
$ alchemyjsonschema --layout=openapi3.0 models.py --format=yaml

実行結果

例えば以下の様なUser,Groupという定義があった時に。

python code

import sqlalchemy as sa
from sqlalchemy.ext.declarative import declared_attr, declarative_base
import sqlalchemy.orm as orm

Base = declarative_base()


class IdMixin:
    @declared_attr
    def id(cls):
        for base in cls.__mro__[1:-1]:
            if getattr(base, '__table__', None) is not None:
                type = sa.ForeignKey(base.id)
                break
        else:
            type = sa.Integer

        return sa.Column(type, primary_key=True)


class Group(IdMixin, Base):
    __tablename__ = "Group"

    name = sa.Column(sa.String(255), default="", nullable=False)


class User(IdMixin, Base):
    __tablename__ = "User"

    name = sa.Column(sa.String(255), default="", nullable=True)
    group_id = sa.Column(sa.Integer, sa.ForeignKey(Group.id), nullable=False)
    group = orm.relationship(Group, uselist=False, backref="users")

openapi2.0用の出力

$ alchemyjsonschema models.py --format=yaml
definitions:
  Group:
    properties:
      id:
        type: integer
      name:
        maxLength: 255
        type: string
      users:
        items:
          $ref: '#/definitions/User'
        type: array
    required:
    - id
    title: Group
    type: object
  User:
    properties:
      group:
        $ref: '#/definitions/Group'
      id:
        type: integer
      name:
        maxLength: 255
        type: string
    required:
    - id
    title: User
    type: object

openapi 3.0用の出力

$ alchemyjsonschema models.py --format=yaml --layout=openapi3.0
components:
  schemas:
    Group:
      properties:
        id:
          type: integer
        name:
          maxLength: 255
          type: string
        users:
          items:
            $ref: '#/components/schemas/User'
          type: array
      required:
      - id
      title: Group
      type: object
    User:
      properties:
        group:
          $ref: '#/components/schemas/Group'
        id:
          type: integer
        name:
          maxLength: 255
          type: string
      required:
      - id
      title: User
      type: object

そういえば

そういえば、ドキュメントテキトウなのどうにかしないと。