json schema @ python 微メモ

先っぽだけよ。

必要としているから・必要になりそうだから、ということではなくて、そういえば、という程度のノリ。ここ数週間での cartopy ネタ、特に geojson を扱った(5)~をやりながら、json schema について思い出していた。これについてはワタシはふざけたタイトルで軽く触れたことがあるのみ。

まぁせっかく思い出して、今時間はあるので、ちょっと見とこうかと思った、てこと。geojson を活用するのに json schema…、無論そんな必要性は限りなく皆無だよ、そういうことではなくて。

json schema についてのインフラがあるとして、それに求めるものは、大きく二段階の段階がある。

一つは無論「処理するにあたうものを受け取れてやがりますか」のチェック(バリデーション)だ。すなわち、たとえば「婚姻届」を処理するアプリケーションが、「受け取ったものが小学生の絵日記ではない」であるとか、「夫となる人、として何人も書かれてない」といった、正式に婚姻届として受理可能なものであるかどうかを確認出来る仕組みだ。

そしてもう一つが、もう一歩進んだ段階として、「最適な型への自動マッピング」がある。たとえば「人口統計」を扱いたい場合に、各支所ごと統計データの「推定人口」というデータカラムを扱うのに、プログラム内で「文字列のまま」扱っていては、シンプルな足し算すらままならないわけだ、たとえば「”3200000″ + “3100000”」が数値演算とならない言語を使っているなら(これが出来る言語は結構少ない)。すなわち、受け取ったデータ読み込み完了時にはこうした数値が、言語がネイティブにサポートする数値型であってくれるとありがたい、ということだ。組み込み型であれば json の場合あまりこれは問題にならないが、固有の class に対してもそういうマッピングが出来るならハッピーだ、てこと。xml であれば pyxb とかに対応するヤツね。

後者のマッピングの方を先に。規模が大きなプロジェクトだとここまで出来るといいだろうし、と思って。

json-schema-codegen なのだが、プロジェクトとしての完成度はちょっとまだまだだなぁってのが第一印象。トップページのもそうだし、examples フォルダに収められてる例が間違ってて動かない、本日時点のものは。まぁどう直せば動くのかは、やればすぐにわかると思うよ。とにかくこの jsonschemacodegen.python.GeneratorFromSchema が、指定ディレクトリに「その schema に従うオブジェクトを専門に扱うモジュール」を書き出してくれる。ただ、おそらくだけれどこの「Supported schema features」は json schema のフルセットではないのだろうなぁ。

出来上がるモジュールがさ、まぁ自動生成にはつきものの「スタイルチェッック的に NG」なものなのよね。かなり無頓着。PEP8 に全く合格しない。けれども「その schema に従う野郎まくがいばー」としてはまさにちゃんと機能はする。良いんではないの、と思うことは思う。とりあえず、「ちゃんとしたプロジェクト」で採用するのは難しいだろうね、こんなだから。けど個人用途だったらこれでも十分かもしんないと思った。

もう一つ、jsonschema-gentypes はこれは説明とは違って今は pip でのインストールは出来ないが、ソースをダンロードして setup.py を動かせば普通にインストール出来る。(ただし、pyyaml をはじめとして、結構いろんなものに依存してるので注意。)cli のジェネレータで生成した結果はこれだけなのよね:

schema がこうだったとして
1 {
2     "type": "object",
3     "properties": {
4         "root": {
5             "default": "abc"
6         }
7     }
8 }
結果はここうこうこうこう
1 from typing import TypedDict
2 
3 
4 _Root = TypedDict('_Root', {
5     # default: abc
6     'root': str,
7 }, total=False)
schema がこうだったとして
 1 {
 2     "title": "Point2D",
 3     "type": "object",
 4     "properties": {
 5         "x": {
 6             "type": "number"
 7         },
 8         "y": {
 9             "type": "number"
10         },
11         "label": {
12             "type": "string"
13         }
14     }
15 }
結果はここうこうこうこう
1 from typing import Union, TypedDict
2 
3 
4 # Point2D
5 Point2D = TypedDict('Point2D', {
6     'x': Union[int, float],
7     'y': Union[int, float],
8     'label': str,
9 }, total=False)

typing…は Python 3.5 からの機能ね。「これだけあれば十分だろ」てことなんだろうが、そうなの?:

Note: The Python runtime does not enforce function and variable type annotations. They can be used by third party tools such as type checkers, IDEs, linters, etc.

ほんとにこれで十分なのなら、これは非常にシンプルでいいのかもしらんけれども、素の python だけではチェック出来ないとして、何を前提にすればいいのかしらね? (おそらく mypy とかなんだろうけれども。)

ところで、これら2つとも、どういうわけだか yaml への依存を強要してくる。ワタシ自身は YAML は好きだし、流行って欲しいとは思っているよ。だけどね、「わっしゃ今ぁ json schema を使おうとしているのだ」ってときに「必ず YAML が必要だ」と脅されたらさ、どう思うよ? これは良くないと思うんだがね。個人の好みをこういう形で押し付けるのは良くないと思うんだ。

ジェネレータについてはその2つだけでとりあえず満足しておいて、その前段のもの。無論これが重鎮なのであろう。

バリデータしか提供されていないものなのだとするならば、たとえば「間違いなく婚姻届なので、「夫となる人」という項目が間違いなく存在していることを前提にして良く、安心して「夫となる人」を取り出すプログラムを書くことが出来る」とか、あるいは「間違いなく年齢が書かれている項目なので、安心して数値に変換して良い」てこと。数値に変換、は、自己責任。そして、「そこまでだけでもやってくれるだけで大助かりだ」と思えるかどうかだけど、まぁ得てしてそれだけで十分だったりもする。

トップページに書かれてる例をそのまんま動かしてみる:

 1 >>> from jsonschema import validate
 2 >>> 
 3 >>> # A sample schema, like what we'd get from json.load()
 4 >>> schema = {
 5 ...     "type" : "object",
 6 ...     "properties" : {
 7 ...         "price" : {"type" : "number"},
 8 ...         "name" : {"type" : "string"},
 9 ...     },
10 ... }
11 >>> 
12 >>> # If no exception is raised by validate(), the instance is valid.
13 >>> validate(instance={"name" : "Eggs", "price" : 34.99}, schema=schema)
14 >>> 
15 >>> validate(
16 ...     instance={"name" : "Eggs", "price" : "Invalid"}, schema=schema,
17 ... )                                   # doctest: +IGNORE_EXCEPTION_DETAIL
18 Traceback (most recent call last):
19   File "<console>", line 1, in <module>
20   File "C:\Program Files\Python39\lib\site-packages\jsonschema\validators.py", line 934, in validate
21     raise error
22 jsonschema.exceptions.ValidationError: 'Invalid' is not of type 'number'
23 
24 Failed validating 'type' in schema['properties']['price']:
25     {'type': 'number'}
26 
27 On instance['price']:
28     'Invalid'

当たり前だが schema が「json ファイルでなければならん」なんてバカな制約を持たせてなくて、それゆえに「必要な場所でやりたくなったらすぐにどこででも」使えて、まぁいい感じよね。こんなんだったらさ、オブジェクト全体にだけ使うんじゃなくて、小さなサブ構造相手にもちょこちょこ使いたいかもしれん。

…気付かない人もいるかもしれないので一応。「json」というプレフィクスを脳内で取り除いても良い、ということである。結局はオブジェクト構造の制約についての扱いをしてくれる、ということだけが本質なのであって、「json」というコンテナはあんまし関係ない。つまりこの json schema を使って YAML のバリデーションをしたって別にいい:

 1 >>> import io
 2 >>> import yaml
 3 >>> from jsonschema import validate
 4 >>> 
 5 >>> # A sample schema, like what we'd get from json.load()
 6 >>> schema = {
 7 ...     "type" : "object",
 8 ...     "properties" : {
 9 ...         "price" : {"type" : "number"},
10 ...         "name" : {"type" : "string"},
11 ...     },
12 ... }
13 >>> 
14 >>> # If no exception is raised by validate(), the instance is valid.
15 >>> validate(instance=yaml.load(io.StringIO("""
16 ... name: Eggs
17 ... price: 34.99
18 ... """), Loader=yaml.loader.FullLoader), schema=schema)
19 >>> 
20 >>> validate(
21 ...     instance=yaml.load(io.StringIO("""
22 ... name: Eggs
23 ... price: Invalid
24 ... """), Loader=yaml.loader.FullLoader), schema=schema,
25 ... )
26 Traceback (most recent call last):
27   File "<console>", line 1, in <module>
28   File "C:\Program Files\Python39\lib\site-packages\jsonschema\validators.py", line 934, in validate
29     raise error
30 jsonschema.exceptions.ValidationError: 'Invalid' is not of type 'number'
31 
32 Failed validating 'type' in schema['properties']['price']:
33     {'type': 'number'}
34 
35 On instance['price']:
36     'Invalid'

無論 schema の方を yaml で書くのも自由:

 1 >>> import io
 2 >>> import yaml
 3 >>> from jsonschema import validate
 4 >>> 
 5 >>> # A sample schema, like what we'd get from json.load()
 6 >>> schema = yaml.load(io.StringIO("""
 7 ... type: object
 8 ... properties:
 9 ...     price:
10 ...         type: number
11 ...     name:
12 ...         type: string
13 ... """), Loader=yaml.loader.FullLoader)
14 >>> 
15 >>> # If no exception is raised by validate(), the instance is valid.
16 >>> validate(instance=yaml.load(io.StringIO("""
17 ... name: Eggs
18 ... price: 34.99
19 ... """), Loader=yaml.loader.FullLoader), schema=schema)
20 >>> 
21 >>> validate(
22 ...     instance=yaml.load(io.StringIO("""
23 ... name: Eggs
24 ... price: Invalid
25 ... """), Loader=yaml.loader.FullLoader), schema=schema,
26 ... )
27 Traceback (most recent call last):
28   File "<console>", line 1, in <module>
29   File "C:\Program Files\Python39\lib\site-packages\jsonschema\validators.py", line 934, in validate
30     raise error
31 jsonschema.exceptions.ValidationError: 'Invalid' is not of type 'number'
32 
33 Failed validating 'type' in schema['properties']['price']:
34     {'type': 'number'}
35 
36 On instance['price']:
37     'Invalid'

XML 相手だと難しいが、「値、シーケンス、ハッシュ」という三つ組だけで構成される「データ用」のコンテナが相手であれば、どんなもの相手でも通用する。msgpack とかね。

ちょっとだけ複雑な例。

正規表現でバリデート出来ちゃうなら大抵のことは出来る、の図:

 1 # -*- coding: utf-8 -*-
 2 from jsonschema import (
 3     validate,
 4 )
 5 
 6 
 7 schema = {
 8     "type": "object",
 9     "properties": {
10         "station_cd" : {
11             "type": "number"
12         },
13         "post": {
14             "type": "string",
15             "pattern": r"^[0-9]{3}\-[0-9]{4}$"
16         }
17     },
18 }
19 
20 # valid
21 validate(
22     instance={
23         "station_cd": 1110101,
24         "post": "040-0063",
25     },
26     schema=schema,
27     )
28 
29 # invalid
30 validate(
31     instance={
32         "station_cd": 1110101,
33         "post": "040-006",
34     },
35     schema=schema,
36     )
 1 Traceback (most recent call last):
 2   File "c:\Users\hhsprings\zzz0.py", line 30, in <module>
 3     validate(
 4   File "C:\Program Files\Python39\lib\site-packages\jsonschema\validators.py", line 934, in validate
 5     raise error
 6 jsonschema.exceptions.ValidationError: '040-006' does not match '^[0-9]{3}\\-[0-9]{4}$'
 7 
 8 Failed validating 'pattern' in schema['properties']['post']:
 9     {'pattern': '^[0-9]{3}\\-[0-9]{4}$', 'type': 'string'}
10 
11 On instance['post']:
12     '040-006'

「”type”: “number”」の例は少し注意深くみて欲しい。先に、「json schema に期待すること」の段階について説明した。つまりバリデータのみをあてにするケースでは、「数値として妥当な文字列」であることのチェックだけを期待して「妥当なので安心して数値変換する」という流れで思考すると思うのだけれど、「”station_cd”: “1110101”」はバリデーションエラーになる。「数値」でなく文字列だから。json のローダを使って素直に読み込んだ場合はこのことが問題になることはおそらくないんだけれど(“1110101″ は数値として解釈されて変換されてしまうので)、入力がそうしたものでない、たとえば csv だったりした場合は、これはちょっと期待するものではない可能性がある、てことね。それが問題のケースでは “pattern” を使っちゃうか、あるいは anyOf を使って:

 1 # -*- coding: utf-8 -*-
 2 from jsonschema import (
 3     validate,
 4 )
 5 
 6 
 7 schema = {
 8     "type": "object",
 9     "properties": {
10         "station_cd" : {
11             "anyOf": [
12                 { "type": "number" },
13                 { "type": "string", "pattern": r"^[0-9]+$" },
14             ]
15         },
16         "post": {
17             "type": "string",
18             "pattern": r"^[0-9]{3}\-[0-9]{4}$"
19         }
20     },
21 }
22 
23 # valid
24 validate(
25     instance={
26         "station_cd": 1110101,
27         "post": "040-0063",
28     },
29     schema=schema,
30     )
31 validate(
32     instance={
33         "station_cd": "1110101",
34         "post": "040-0063",
35     },
36     schema=schema,
37     )
38 
39 # invalid
40 validate(
41     instance={
42         "station_cd": 1110101,
43         "post": "040-006",
44     },
45     schema=schema,
46     )

とかってするのだろうね。

「format」のうち、定義済のやつ:

 1 # -*- coding: utf-8 -*-
 2 # For "Validating Formats", you may need:
 3 #   $ pip install jsonschema[format]
 4 # or
 5 #   $ pip install jsonschema[format_nongpl]
 6 from jsonschema import (
 7     validate,
 8     draft7_format_checker
 9 )
10 
11 
12 schema = {
13     "type": "object",
14     "properties": {
15         "station_cd" : {
16             "type": "number"
17         },
18         "post": {
19             "type": "string",
20             "pattern": r"^[0-9]{3}\-[0-9]{4}$"
21         },
22         "open_ymd": {
23             "type": "string",
24             "format": "date"
25         },
26     },
27 }
28 
29 # valid
30 validate(
31     instance={
32         "station_cd": 1110101,
33         "post": "040-0063",
34         "open_ymd": "1902-12-10",
35     },
36     schema=schema,
37     format_checker=draft7_format_checker)
38 
39 # invalid
40 validate(
41     instance={
42         "station_cd": 1110101,
43         "post": "040-0063",
44         "open_ymd": "1902-13-52",
45     },
46     schema=schema,
47     format_checker=draft7_format_checker)
 1 Traceback (most recent call last):
 2   File "C:\Program Files\Python39\lib\site-packages\jsonschema\_format.py", line 97, in check
 3     result = func(instance)
 4   File "C:\Program Files\Python39\lib\site-packages\jsonschema\_format.py", line 335, in is_date
 5     return datetime.datetime.strptime(instance, "%Y-%m-%d")
 6   File "C:\Program Files\Python39\lib\_strptime.py", line 568, in _strptime_datetime
 7     tt, fraction, gmtoff_fraction = _strptime(data_string, format)
 8   File "C:\Program Files\Python39\lib\_strptime.py", line 349, in _strptime
 9     raise ValueError("time data %r does not match format %r" %
10 ValueError: time data '1902-13-52' does not match format '%Y-%m-%d'
11 
12 The above exception was the direct cause of the following exception:
13 
14 Traceback (most recent call last):
15   File "c:\Users\hsprings\zzz1.py", line 40, in <module>
16     validate(
17   File "C:\Program Files\Python39\lib\site-packages\jsonschema\validators.py", line 934, in validate
18     raise error
19 jsonschema.exceptions.ValidationError: '1902-13-52' is not a 'date'
20 
21 Failed validating 'format' in schema['properties']['open_ymd']:
22     {'format': 'date', 'type': 'string'}
23 
24 On instance['open_ymd']:
25     '1902-13-52'

よいね。

ところで jsonschema パッケージリファレンスがところどころ不親切で悩ましいのだが、対照的にというかなんというか、json schema 本家の Understanding JSON Schemaが大変良くできていて、非常に助かる。python 以外のプロジェクトが Sphinx を採用している例はワタシが望んでいるほどには多くはないのだけれど、やっぱし python でないのに Sphinx を採用するようなプロジェクトって、わかりやすいドキュメントを書こうとする意欲が高いと思う。「Sphinx 最強説」は結局書き手にその意欲を持たせる、てところにあるのだからね。

ひとまず今の自分的には、自動マッピングまではいらんかなぁって感じ。Python 3.5+ 機能の typing についてもっとわかってくれば jsonschema-gentypes (もしくは jsonschema-typed)を愛用する可能性はないではないけれど、いまのところ jsonschema のバリデーションだけでいいかなぁ、て感じ。