Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: pydanticをv2へ #1184

Merged
merged 7 commits into from
Jun 10, 2024
Merged

Conversation

sabonerune
Copy link
Contributor

@sabonerune sabonerune commented Apr 17, 2024

内容

Pydanticを2.7へ更新します。

ref: Migration Guide - Pydantic

現状FastAPIのバグが原因と思われる問題があるためドラフトにしています。

関連 Issue

その他

現状の問題

特に問題になりそうなのは以下の二つです。

OpenAPIスキーマーがおかしい

オプションのクエリやフォームでnullが使用可能と記述されています。
これは多分誤りです。

-> pydantic.json_schema.SkipJsonSchemaで一応回避が可能。

Swagger UIの表示が壊れている

上記が原因でドキュメントのUIが壊れています。

比較

その他気になる挙動

  • test_post_user_dict_word_422.jsonでPydanticの情報まで出てきている?
    これは意図的?
    FastAPI 0.110.2で解消

  • separate_input_output_schemas=Trueにしたときドキュメントの記載と動作が異なる?
    Falseに指定したため影響はない
    ref: Separate OpenAPI Schemas for Input and Output or Not - FastAPI

Copy link

github-actions bot commented Apr 17, 2024

Coverage Result

Resultを開く
Name Stmts Miss Cover
run.py 278 122 coverage-56%
voicevox_engine/init.py 1 0 coverage-100%
voicevox_engine/app/init.py 0 0 coverage-100%
voicevox_engine/app/dependencies.py 9 0 coverage-100%
voicevox_engine/app/routers/init.py 0 0 coverage-100%
voicevox_engine/app/routers/preset.py 37 4 coverage-89%
voicevox_engine/app/routers/setting.py 25 3 coverage-88%
voicevox_engine/app/routers/speaker.py 60 5 coverage-92%
voicevox_engine/app/routers/tts_pipeline.py 126 29 coverage-77%
voicevox_engine/app/routers/user_dict.py 61 29 coverage-52%
voicevox_engine/cancellable_engine.py 97 75 coverage-23%
voicevox_engine/core/init.py 0 0 coverage-100%
voicevox_engine/core/core_adapter.py 81 6 coverage-93%
voicevox_engine/core/core_initializer.py 60 30 coverage-50%
voicevox_engine/core/core_wrapper.py 228 160 coverage-30%
voicevox_engine/dev/init.py 0 0 coverage-100%
voicevox_engine/dev/core/init.py 0 0 coverage-100%
voicevox_engine/dev/core/mock.py 65 2 coverage-97%
voicevox_engine/dev/tts_engine/init.py 0 0 coverage-100%
voicevox_engine/dev/tts_engine/mock.py 28 0 coverage-100%
voicevox_engine/engine_manifest/EngineManifest.py 36 0 coverage-100%
voicevox_engine/engine_manifest/EngineManifestLoader.py 16 0 coverage-100%
voicevox_engine/engine_manifest/init.py 0 0 coverage-100%
voicevox_engine/library_manager.py 92 4 coverage-96%
voicevox_engine/metas/Metas.py 36 0 coverage-100%
voicevox_engine/metas/MetasStore.py 29 1 coverage-97%
voicevox_engine/metas/init.py 0 0 coverage-100%
voicevox_engine/model.py 180 3 coverage-98%
voicevox_engine/morphing.py 72 4 coverage-94%
voicevox_engine/preset/Preset.py 13 0 coverage-100%
voicevox_engine/preset/PresetError.py 2 0 coverage-100%
voicevox_engine/preset/PresetManager.py 82 2 coverage-98%
voicevox_engine/preset/init.py 0 0 coverage-100%
voicevox_engine/setting/Setting.py 9 0 coverage-100%
voicevox_engine/setting/SettingLoader.py 20 0 coverage-100%
voicevox_engine/setting/init.py 0 0 coverage-100%
voicevox_engine/tts_pipeline/init.py 0 0 coverage-100%
voicevox_engine/tts_pipeline/kana_converter.py 88 1 coverage-99%
voicevox_engine/tts_pipeline/mora_mapping.py 7 0 coverage-100%
voicevox_engine/tts_pipeline/phoneme.py 34 0 coverage-100%
voicevox_engine/tts_pipeline/text_analyzer.py 146 6 coverage-96%
voicevox_engine/tts_pipeline/tts_engine.py 288 12 coverage-96%
voicevox_engine/user_dict/part_of_speech_data.py 5 0 coverage-100%
voicevox_engine/user_dict/user_dict.py 146 12 coverage-92%
voicevox_engine/utility/init.py 0 0 coverage-100%
voicevox_engine/utility/connect_base64_waves.py 37 0 coverage-100%
voicevox_engine/utility/core_utility.py 6 0 coverage-100%
voicevox_engine/utility/core_version_utility.py 8 1 coverage-88%
voicevox_engine/utility/mutex_utility.py 13 0 coverage-100%
voicevox_engine/utility/path_utility.py 26 6 coverage-77%
voicevox_engine/utility/run_utility.py 10 7 coverage-30%
TOTAL 2557 524 coverage-80%

@sabonerune sabonerune force-pushed the maint/pydantic-v2 branch 2 times, most recently from fbddb05 to 280e867 Compare April 26, 2024 17:28
@Hiroshiba
Copy link
Member

バグが直るの楽しみです…!!

@sabonerune sabonerune force-pushed the maint/pydantic-v2 branch from 280e867 to 0e9f059 Compare May 6, 2024 09:38
@sabonerune sabonerune force-pushed the maint/pydantic-v2 branch 2 times, most recently from 85435d2 to a111f9a Compare May 20, 2024 09:47
@sabonerune sabonerune force-pushed the maint/pydantic-v2 branch 2 times, most recently from 9c10495 to c9aa572 Compare May 31, 2024 09:07
@sabonerune sabonerune force-pushed the maint/pydantic-v2 branch from c9aa572 to 66c6ff1 Compare June 6, 2024 13:40
@sabonerune
Copy link
Contributor Author

OpenAPIスキーマーの問題は恐らく仕様らしいのでドラフトを解除します。

@sabonerune sabonerune marked this pull request as ready for review June 6, 2024 15:22
@sabonerune sabonerune requested a review from a team as a code owner June 6, 2024 15:22
@sabonerune sabonerune requested review from Hiroshiba and removed request for a team June 6, 2024 15:22
@sabonerune
Copy link
Contributor Author

Swagger UIが壊れる問題に対処するためSkipJsonSchema[None]を使用しています。
ref: fastapi/fastapi#10654

Copy link
Contributor

@tarepan tarepan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • 👍️ 移行ガイドに沿い、適切に関数・クラスを変更しています。good work!
  • 👍️ 移行以外の変更を極力控え、レビューしやすい変更です。great!
  • 変数名をより明確にし、更に可読性を向上できます
  • 機能追加・変更か否か、確認が必要な箇所があります

レビュー用情報

  • リネーム
  • 置き換え
    • parse_obj_as()TypeAdapter (migration docs)
    • | None| SkipJsonSchema[None]

voicevox_engine/library/library_manager.py Outdated Show resolved Hide resolved
test/e2e/test_speakers.py Outdated Show resolved Hide resolved
voicevox_engine/app/routers/speaker.py Outdated Show resolved Hide resolved
voicevox_engine/app/routers/user_dict.py Show resolved Hide resolved
voicevox_engine/engine_manifest.py Outdated Show resolved Hide resolved
voicevox_engine/preset/preset_manager.py Outdated Show resolved Hide resolved
voicevox_engine/setting/setting_manager.py Show resolved Hide resolved
voicevox_engine/tts_pipeline/model.py Outdated Show resolved Hide resolved
voicevox_engine/user_dict/model.py Outdated Show resolved Hide resolved
"type": "string"
},
{
"type": "null"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"required": false でなく "anyOf null" になっているように見えます。
意図的な変更でしょうか?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pydantic V2の仕様変更で出力されるスキーマーが正確になったという感じです。

Copy link
Contributor

@tarepan tarepan Jun 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

スキーマが正確になったのではなく、マイグレーション不足で意図しないスキーマ変更が起きていると考えます(以下、検証と考察)。

スキーマに diff が入っている UpdateInfo.contributors 属性について、list[str] | Nonelist[str] | SkipJsonSchema[None] とすると diff が無くなりました。
これは PR 冒頭にあるとおり、: X | None を Nullable と解釈する仕様変更に伴うものです。

このマイグレーションをしない場合、新スキーマは required: false かつ Nullable になり、今までの「field に値を入れる or field ごと無い」から「field に値を入れる or field に Null を入れる or field ごと無い」へ VOICEVOX API の仕様変更が起きます。

ゆえに SkipJsonSchema を用いてマイグレーションすべき箇所と考えます。

Copy link
Member

@Hiroshiba Hiroshiba Jun 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

何を正しいとするかだと思います。
V1→V2の変更で、今のPython実装に合うようになります。

例えばpause_moraはrequired=falseですが、

pause_mora: Mora | None = Field(title="後ろに無音を付けるかどうか")

実態としてnoneが返ってきてました。。

Copy link
Contributor

@tarepan tarepan Jun 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

今のPython実装に合うようになります

👍️
スキーマと実装に乖離があるのですね、現状を把握しました。

@Hiroshiba
スキーマを実態に合わせて変更することは仕様変更(update or fix)です。
リファクタリングの一種である bump PR で仕様変更をするのはバットプラクティスです(悪影響例: update_infos.json 作るときに 開発環境の向上 と誤認して記載を忘れる)。
仕様変更は後続 PR でおこなうのが適切と考えます。

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ふと思い出したのですがFastAPIがpydantic V2のModelと同じ挙動になるのはないとおもいます。
というのもseparate_input_output_schemasという形でPydanticの入力と出力の対応が異なるという問題に対処しているからです。

今回のPRではこれをTrueにしてしまうとスキーマーが大きく変わってしまうためFalseにしてあります。

Copy link
Member

@Hiroshiba Hiroshiba Jun 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ちゃんとわかってないかもなのですが、separate_input_output_schemas=trueなら同じ挙動になる気がします。

PydanticはV2であれV1であれ「キーがない」という型を表現できないんですよね。
APIの入力側はこの仕様でも問題ないはず。
pydanticに合わせてvalue=nullがAPIに投げられてもOK、投げなくてもOKにすれば良いだけなので。

問題は出力側で、pydanticは必ず全キーがあるモデルしか定義できないから、出力は必ずvalue=nullになる。
この入出力のずれの折衷案がFastAPIのseparate_input_output_schemas=trueで、実装をpydanticに合わせた形だと思います。
逆にV2&separate_input_output_schemas=falseやV1のときはスキーマと実装がずれちゃってます。

よくよく考えると、別にこの辺りでpydanticがV1からV2になって変わったことはないはずで、V2に対応するときにFastAPI側がバグっぽい挙動を直したって感じな気がしますねぇ

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pydanticは必ず全キーがあるモデルしか定義できないから、出力は必ずvalue=nullになる。

pydantic にはこれに対応するための仕組みがあります。

from pydantic import BaseModel, Field
from pydantic.json_schema import SkipJsonSchema


class APIModel(BaseModel):
  required: int
  not_required: int | SkipJsonSchema[None] = None


api_model = APIModel(required=10)
print(api_model)
# required=10 not_required=None

api_model_dict = api_model.model_dump()
print(api_model_dict)
# {'required': 10, 'not_required': None}

api_model_dict = api_model.model_dump(exclude_none=True)
print(api_model_dict)
# {'required': 10}

Copy link
Member

@Hiroshiba Hiroshiba Jun 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

それは・・・noneをexcludeしているのであって、キーがないのを表現できてるわけではない気がします!

実際

  • RequiredでNoneが入りうる型
  • NonRequiredでNoneが入らない型

の2つが同時に満たせないかなと

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • RequiredでNoneが入りうる型
  • NonRequiredでNoneが入らない型

の2つが同時に満たせないかなと

一応できた?

from typing import Annotated, Any

from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
    ValidationInfo,
    ValidatorFunctionWrapHandler,
    WrapValidator,
)
from pydantic.json_schema import SkipJsonSchema
from pydantic_core import PydanticCustomError


def non_null_validator(
    v: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo
) -> Any:
    result = handler(v)
    if result is None:
        raise PydanticCustomError(
            "null_error",
            '"{field_name}" is optional but cannot be null.',
            {"field_name": info.field_name},
        )
    return result


class Model(BaseModel):
    model_config = ConfigDict(validate_assignment=True)
    required: int
    optional: Annotated[
        int | SkipJsonSchema[None],
        WrapValidator(non_null_validator),
    ] = Field(default=None)
    required_nullable: int | None
    optional_nullable: int | None = Field(default=None)


try:
    Model(required=10, optional=None)
except ValueError as e:
    print(e)
    # 2 validation errors for Model
    # optional
    #   "optional" is optional but cannot be null. [type=null_error, input_value=None, input_type=NoneType]
    # required_nullable
    #   Field required [type=missing, input_value={'required': 10, 'optional': None}, input_type=dict]
    #     For further information visit https://errors.pydantic.dev/2.7/v/missing
m = Model(required=10, required_nullable=None, optional_nullable=None)
print(m)
# required=10 optional=None required_nullable=None optional_nullable=None
m.optional = 5
try:
    m.optional = None
except ValueError as e:
    print(e)
    # optional
    #   "optional" is optional but cannot be null. [type=null_error, input_value=None, input_type=NoneType]
del m.optional
print(m)
# required=10 required_nullable=None optional_nullable=None
print(m.model_dump_json(exclude_unset=True))
# {"required":10,"required_nullable":null,"optional_nullable":null}

del m.optionalが怪しいですが…

あとexclude_none=Trueexclude_unset=True両方に言えるのですがこの設定はFastAPIのエンドポイントやpydanticのdump時に指定するものでモデルそのものに設定できないといのも問題だと思います。
(カスタムシリアライザを使えば表現可能な気がしますが管理が面倒だと思います。)

また、既存のモデルにこのnon_null_validatorを入れると保存しておいた以前のエンジンで生成したクエリをそのまま新しいエンジンに投げるとエラーになるという実質的な破壊的変更が起こります。

Copy link
Contributor

@tarepan tarepan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM👍️

Copy link
Member

@Hiroshiba Hiroshiba left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!!!

なんかいろいろ考えたのですが、めちゃくちゃややこしいですね!!!
スキーマの互換性まで考えると、もう「キーがない」==「デフォルト値がNone」くらいに制限強めないとダメな気がしてきました。。

1箇所コメント追加だけこちらでさせていただきます!

Comment on lines 22 to +24
def pydantic_to_native_type(value: Any) -> Any:
"""pydanticの型をnativeな型に変換する"""
return json.loads(json.dumps(value, default=pydantic_encoder))
return jsonable_encoder(value)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この関数不要になったかもですね!

voicevox_engine/app/application.py Outdated Show resolved Hide resolved
Comment on lines 56 to +63
priority: Annotated[
int | None,
int | SkipJsonSchema[None],
Query(
ge=MIN_PRIORITY,
le=MAX_PRIORITY,
description="単語の優先度(0から10までの整数)。数字が大きいほど優先度が高くなる。1から9までの値を指定することを推奨",
# "SkipJsonSchema[None]"の副作用でスキーマーが欠落する問題に対するワークアラウンド
json_schema_extra={"maximum": MAX_PRIORITY, "minimum": MIN_PRIORITY},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

入力側はSkipJsonSchemaを外しても破壊的変更になってないので外して良さそう

Comment on lines -21 to +22
type: StyleType | None = Field(
type: StyleType = Field(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

あっ ここが| Noneなのは互換性のためで、昔はこのキーなかったんですよね・・・。
ということで必要かもです。。。

・・・・・・・・・・・・・あれ、いやこれはこの形が以前から正しいのか・・・・・・・。

ん???
あれじゃあpydanticを使っている以上、互換性維持したままキーの追加が本質的にできない・・・・・・・?
だいぶ混乱してきた、よくわかんないですね!!! 😇

@Hiroshiba Hiroshiba merged commit 2513168 into VOICEVOX:master Jun 10, 2024
4 checks passed
@sabonerune sabonerune deleted the maint/pydantic-v2 branch June 10, 2024 22:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants