pythonの型定義を使ってprotobufを出力してみる。

この記事の作業の一環で、pythonの型定義の情報を使いprotobufを出力することを試してみた。

基本的にはアイデアスケッチのようなもので、ちょっとしたコード辺を書いてみて、上手く期待する機能が手に入るかどうかを確認するようなもの。

自分にとってはprocessingの人がやっている##dailycodingchallengeのようなものなのだけれど、まぁそれ以上に共感や閲覧はしてもらえない類のものではある。

今回期待したかったのはこのあたり。

  • python上でimportしたオブジェクトの利用が素直にimportされたものとして扱われる
  • (このとき、importの順序などを気にすることなく、素直に自動で追加されることが大切)
  • t.List[User] のような __name__ を持たないあるいはクラスにならない表現をどうにか解釈する方法の獲得
  • pythonでの自然なクラス定義の表現力が十分実用に足るものであるかの確認

あと、protobufとの比較で使用感の確認のようなものをしようとした。書いてみて思ったのはprotobufの定義はよくできているなーということ。やっぱりフィールドにannotationを追加したりしたいし。rpcの部分の表現はいわゆる関数やメソッドの定義に似ているものなのだけれど。body部分にoptionの記述を書くというのは理に叶っているなーというような感じ。

protobuf

grpcなどで使われるフォーマットのこと。

今回はこの内の前者のページで使われている定義の表現をそのまま出力してみることにする。実際のコードはgistにだけあげる。 (例によって開発途中のコードに依存しているのでおそらく自分の環境でしか動かない)

実行例

まず期待する出力結果はこちら。

expected.proto

syntax = "proto3";

package myapp;

import "google/api/annotations.proto";
import "google/type/date.proto";
import "google/protobuf/empty.proto";

message User {
  uint64 id = 1;
  string first_name = 2;
  string family_name = 3;
  Sex sex = 4;
  uint32 age = 5 [ deprecated = true ];
  google.type.Date birthday = 6;
}

enum Sex {
  SEX_UNKNOWN = 0;
  MALE = 1;
  FEMALE = 2;
  OTHER = 3;
}

message UserList { repeated User users = 1; }

service UserService {
  rpc Get(GetRequest) returns (User) {
    option deprecated = false;
    option (google.api.http) = {
      get : "user"
    };
  }
  rpc List(google.protobuf.Empty) returns (UserList) {}
}

message GetRequest { uint64 id = 1; }

実際にpythonのクラス定義から出力した結果との差分はこちら (diff -u expected.proto result.proto )。

大まかに以下の点が異なっている。

  • rpcの定義の中でのoption指定を省略している
  • deprecatedの修飾を省略している
  • UserListの定義位置が異なっている
  • (ネストした構造に対応していない(やってやれないことはないけれど))
--- expected.proto   2020-05-10 23:51:12.000000000 +0900
+++ result.proto  2020-05-10 23:52:30.000000000 +0900
@@ -2,7 +2,6 @@
 
 package myapp;
 
-import "google/api/annotations.proto";
 import "google/type/date.proto";
 import "google/protobuf/empty.proto";
 
@@ -11,7 +10,7 @@
   string first_name = 2;
   string family_name = 3;
   Sex sex = 4;
-  uint32 age = 5 [ deprecated = true ];
+  uint32 age = 5;
   google.type.Date birthday = 6;
 }
 
@@ -22,17 +21,19 @@
   OTHER = 3;
 }
 
-message UserList { repeated User users = 1; }
-
 service UserService {
   rpc Get(GetRequest) returns (User) {
-    option deprecated = false;
-    option (google.api.http) = {
-      get : "user"
-    };
+
+  }
+  rpc List(google.protobuf.Empty) returns (UserList) {
+
   }
-  rpc List(google.protobuf.Empty) returns (UserList) {}
 }
 
-message GetRequest { uint64 id = 1; }
+message GetRequest {
+  uint64 id = 1;
+}
 
+message UserList {
+  repeated User users = 1;
+}

実際にやってみる

入力として取りたいpythonのコードはこちら。そこそこ素直なpythonでの表現だと思う。

  • messageの作成はpythonのクラス定義そのものtype hintsでfieldの型情報を追加している
  • (from __future__ import anntations のおかげで再帰的な構造や事後に読む必要がある型の順序など全く考える必要がなくなった)
  • enumの定義はpythonenum
  • 他のパッケージに存在しているものはstubから読み込んでいる (自動でimportが追加される)
  • rpcの定義はServiceクラスを継承したもの
from __future__ import annotations
import enum
import typing as t
from typestubs import uint32, uint
from typestubs import Date, Empty
from _emit import Service


class User:
    id: uint
    first_name: str
    family_name: str
    sex: Sex
    age: uint32
    birthday: Date


# https://en.wikipedia.org/wiki/ISO/IEC_5218 でなければautoが使える
@enum.unique
class Sex(enum.IntEnum):
    SEX_UNKNOWN = 0
    MALE = 1
    FEMALE = 2
    OTHER = 3  # 9


UserList = t.List[User]


class UserService(Service):
    def Get(self, req: GetRequest) -> User:
        pass

    def List(self, empty: Empty) -> UserList:
        pass


class GetRequest:
    id: uint

ちなみにtypestubs.pyは以下のようなもの。

import typing as t


def proto_package(path: str) -> t.Callable[[t.Type[t.Any]], t.Type[t.Any]]:
    def _deco(cls: t.Type[t.Any]) -> t.Type[t.Any]:
        cls.PROTO_PACKAGE = path
        return cls

    return _deco


#
# uint32,uint64などの違いがある?
uint64 = t.NewType("uint64", int)
uint32 = t.NewType("uint32", int)
uint = uint64


@proto_package("google/type/date.proto")
class Date:
    pass


@proto_package("google/protobuf/empty.proto")
class Empty:
    pass

何を気にしたかったか?

冒頭でも書いたのだけれど。気にしたかったのはまとめるとこの2つ。

  • t.List[User] のような __name__ を持たないものの対応
  • 自動で素直な import の追加

t.List[User] のような __name__ を持たないものの対応

まずそもそもどういうことかということを説明しておくと。pythonのクラスは __name__ に自身の名前を持つ(ちなみにネストした時にそのことを知りたい場合には __qualname__ を見ると良い)。なのでコード生成の入力となった場合に、クラス定義で定義を書くというのはけっこう自然な選択。

class User:
    name: str

print(User.__name__)
# => "User"

また前述したとおりPEP563のおかげで一度型定義を文字列として読み込んでから解釈される様になったので再帰的な型ヒントを持つような構造も自然に書ける様になった。

ただしよくあるmap(dict), array(list)のようなコンテナ型の定義もカバーしておきたい。一方でpythonの型ヒントをそのまま使うと名前を持たない。それはそう。ただの変数なので。

UserList = List[User]

幾つかの値に対しては自前で __name__ を代入してやることで動かす事ができるようにはなる。

UserList = List[User]
UserList.__name__ = "UserList"

ただし、これをユーザーに強いるのは不格好ではあるし、List[User]はsingleton的な形になってしまうので、別のパッケージに登録された同じ形状の値を管理することができなくなる。そんなわけでこれを自然に扱う方法を考えていきたい。

自動で素直な import の追加

こちらはやりたいこと自体は説明することなく解るものだと思う。ただ詳しく説明すると、egoistの元々の始まりYAMLJSONなどの設定ファイルには、「便利な型定義も、モジュールシステムも、親切なエラーレポーティング機能も存在しない」と言うところから端を発している。そして、時間が許すなら自作のDSLが最高ではあるけれど、まぁマーケティング的な意味でも難しいよね、、というところで既存の言語(この場合はpython)を流用するという意思決定をしていた。

そんなわけでモジュールシステムがそのまま使えないとだいぶ辛い。それも素直にimportしたものが素直に出力されてほしい。ここでの素直な出力とは、「いつどの箇所に書いても、出力結果のちょうど良い位置に挿入され重複を気にすることも無い」というようなこと。

実はもう一つ意味がある。自然なimportというのはimportされたものが使われた場合には、そのモジュールでimportされたかのように扱われてほしい。今回の例では Date 型などがそれに当たる。

import "google/type/date.proto";

message User {
...
  google.type.Date birthday = 6;
}

例えば、"google/type/date.proto" でimportされたものは、 google.type というprefix付きで出力されてほしい。このための出力の調整などをユーザーに書かせるような記法は使い勝手が良くない。

pythonの世界に持ってくるなら少なくともこのように使えてほしい。

from typestubs import Date, Empty

class User:
...
    birthday: Date

これはprotobufの今回の例などに限らず、元々の適用例でのgoなどでも生じること。

# 例えばこれは
fmt_pkg = m.import_("fmt")
fmt.Printf(<object>)

# このように出力されてほしい
import (
...
    "fmt"
)
fmt.Printf(<objectの展開結果>)

場合によっては、python名前空間そのものを出力先の言語の名前空間に対応させる事ができると便利ではあるかもしれない(とはいえgoなどで考えた場合に"/"と"."の扱いが悩ましい)。

加えて大切なのは表現としては切れていること。ロジック部分も含めて全てをpythonで書くということがしたいわけではないので。例えば余談になるがDIを考えるときに、個別のstruct定義を無視して"provider関数"として捉える事ができると便利。

まとめ

pythonのクラス定義からprotobufの定義を出力しようとしてみた。意外といける。

gist