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

vvlib_manifest.jsonをバリデートするようにする #694

Merged
merged 17 commits into from
Jun 20, 2023
1 change: 1 addition & 0 deletions engine_manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"terms_of_service": "engine_manifest_assets/terms_of_service.md",
"update_infos": "engine_manifest_assets/update_infos.json",
"dependency_licenses": "engine_manifest_assets/dependency_licenses.json",
"supported_vvlib_manifest_version": "0.15.0",
"supported_features": {
"adjust_mora_pitch": {
"type": "bool",
Expand Down
24 changes: 13 additions & 11 deletions run.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,16 @@ async def block_origin_middleware(request: Request, call_next):
preset_manager = PresetManager(
preset_path=root_dir / "presets.yaml",
)
engine_manifest_loader = EngineManifestLoader(
engine_manifest_data = EngineManifestLoader(
root_dir / "engine_manifest.json", root_dir
).load_manifest()
library_manager = LibraryManager(
get_save_dir() / "installed_libraries",
engine_manifest_data.supported_vvlib_manifest_version,
engine_manifest_data.brand_name,
engine_manifest_data.name,
engine_manifest_data.uuid,
)
library_manager = LibraryManager(get_save_dir() / "installed_libraries")

metas_store = MetasStore(root_dir / "speaker_info")

Expand Down Expand Up @@ -812,8 +818,7 @@ def downloadable_libraries():
-------
ret_data: List[DownloadableLibrary]
"""
manifest = engine_manifest_loader.load_manifest()
if not manifest.supported_features.manage_library:
if not engine_manifest_data.supported_features.manage_library:
raise HTTPException(status_code=404, detail="この機能は実装されていません")
return library_manager.downloadable_libraries()

Expand All @@ -830,8 +835,7 @@ def installed_libraries():
-------
ret_data: List[DownloadableLibrary]
"""
manifest = engine_manifest_loader.load_manifest()
if not manifest.supported_features.manage_library:
if not engine_manifest_data.supported_features.manage_library:
raise HTTPException(status_code=404, detail="この機能は実装されていません")
return library_manager.installed_libraries()

Expand All @@ -850,8 +854,7 @@ async def install_library(library_uuid: str, request: Request):
library_uuid: str
音声ライブラリのID
"""
manifest = engine_manifest_loader.load_manifest()
if not manifest.supported_features.manage_library:
if not engine_manifest_data.supported_features.manage_library:
raise HTTPException(status_code=404, detail="この機能は実装されていません")
archive = BytesIO(await request.body())
loop = asyncio.get_event_loop()
Expand All @@ -874,8 +877,7 @@ def uninstall_library(library_uuid: str):
library_uuid: str
音声ライブラリのID
"""
manifest = engine_manifest_loader.load_manifest()
if not manifest.supported_features.manage_library:
if not engine_manifest_data.supported_features.manage_library:
raise HTTPException(status_code=404, detail="この機能は実装されていません")
library_manager.uninstall_library(library_uuid)
return Response(status_code=204)
Expand Down Expand Up @@ -1063,7 +1065,7 @@ def supported_devices(

@app.get("/engine_manifest", response_model=EngineManifest, tags=["その他"])
def engine_manifest():
return engine_manifest_loader.load_manifest()
return engine_manifest_data

@app.post(
"/validate_kana",
Expand Down
180 changes: 180 additions & 0 deletions test/test_downloadable_library.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import copy
import glob
import json
import os
from io import BytesIO
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import TestCase
from zipfile import ZipFile

from fastapi import HTTPException

from voicevox_engine.downloadable_library import LibraryManager

vvlib_manifest_name = "vvlib_manifest.json"


class TestLibraryManager(TestCase):
def setUp(self):
super().setUp()
self.tmp_dir = TemporaryDirectory()
self.tmp_dir_path = Path(self.tmp_dir.name)
self.engine_name = "Test Engine"
self.library_manger = LibraryManager(
self.tmp_dir_path,
"0.15.0",
"Test",
self.engine_name,
"c7b58856-bd56-4aa1-afb7-b8415f824b06",
)
self.library_filename = Path("test/test.vvlib")
with open("test/vvlib_manifest.json") as f:
self.vvlib_manifest = json.loads(f.read())
self.library_uuid = self.vvlib_manifest["uuid"]
with ZipFile(self.library_filename, "w") as zf:
speaker_infos = glob.glob("speaker_info/**", recursive=True)
for info in speaker_infos:
zf.write(info)
zf.writestr(vvlib_manifest_name, json.dumps(self.vvlib_manifest))
self.library_file = open(self.library_filename, "br")

def tearDown(self):
self.tmp_dir.cleanup()
self.library_file.close()
self.library_filename.unlink()

def create_vvlib_without_manifest(self, filename: str):
with ZipFile(filename, "w") as zf_out, ZipFile(
self.library_filename, "r"
) as zf_in:
for file in zf_in.infolist():
buffer = zf_in.read(file.filename)
if file.filename != vvlib_manifest_name:
zf_out.writestr(file, buffer)

def create_vvlib_manifest(self, **kwargs):
vvlib_manifest = copy.deepcopy(self.vvlib_manifest)
return {**vvlib_manifest, **kwargs}

def test_installed_libraries(self):
self.assertEqual(self.library_manger.installed_libraries(), {})

self.library_manger.install_library(
self.library_uuid,
self.library_file,
)
# 内容はdownloadable_library.jsonを元に生成されるので、内容は確認しない
self.assertEqual(
list(self.library_manger.installed_libraries().keys())[0], self.library_uuid
)

self.library_manger.uninstall_library(self.library_uuid)
self.assertEqual(self.library_manger.installed_libraries(), {})

def test_install_library(self):
# エンジンが把握していないライブラリのテスト
with self.assertRaises(HTTPException) as e:
self.library_manger.install_library(
"52398bd5-3cc3-406c-a159-dfec5ace4bab", self.library_file
)
self.assertEqual(e.exception.detail, "指定された音声ライブラリが見つかりません。")

# 不正なZIPファイルのテスト
with self.assertRaises(HTTPException) as e:
self.library_manger.install_library(self.library_uuid, BytesIO())
self.assertEqual(e.exception.detail, "不正なZIPファイルです。")

# vvlib_manifestの存在確認のテスト
invalid_vvlib_name = "test/invalid.vvlib"
self.create_vvlib_without_manifest(invalid_vvlib_name)
with open(invalid_vvlib_name, "br") as f, self.assertRaises(HTTPException) as e:
self.library_manger.install_library(self.library_uuid, f)
self.assertEqual(e.exception.detail, "指定された音声ライブラリにvvlib_manifest.jsonが存在しません。")

# vvlib_manifestのパースのテスト
# Duplicate name: 'vvlib_manifest.json'とWarningを吐かれるので、毎回作り直す
self.create_vvlib_without_manifest(invalid_vvlib_name)
with ZipFile(invalid_vvlib_name, "a") as zf:
zf.writestr(vvlib_manifest_name, "test")

with open(invalid_vvlib_name, "br") as f, self.assertRaises(HTTPException) as e:
self.library_manger.install_library(self.library_uuid, f)
self.assertEqual(e.exception.detail, "指定された音声ライブラリのvvlib_manifest.jsonは不正です。")

# vvlib_manifestのパースのテスト
invalid_vvlib_manifest = self.create_vvlib_manifest(version=10)
self.create_vvlib_without_manifest(invalid_vvlib_name)
with ZipFile(invalid_vvlib_name, "a") as zf:
zf.writestr(vvlib_manifest_name, json.dumps(invalid_vvlib_manifest))

with open(invalid_vvlib_name, "br") as f, self.assertRaises(HTTPException) as e:
self.library_manger.install_library(self.library_uuid, f)
self.assertEqual(
e.exception.detail, "指定された音声ライブラリのvvlib_manifest.jsonに不正なデータが含まれています。"
)

# vvlib_manifestの不正なversionのテスト
invalid_vvlib_manifest = self.create_vvlib_manifest(version="10")
self.create_vvlib_without_manifest(invalid_vvlib_name)
with ZipFile(invalid_vvlib_name, "a") as zf:
zf.writestr(vvlib_manifest_name, json.dumps(invalid_vvlib_manifest))

with open(invalid_vvlib_name, "br") as f, self.assertRaises(HTTPException) as e:
self.library_manger.install_library(self.library_uuid, f)
self.assertEqual(e.exception.detail, "指定された音声ライブラリのversionが不正です。")

# vvlib_manifestの不正なmanifest_versionのテスト
invalid_vvlib_manifest = self.create_vvlib_manifest(manifest_version="10")
self.create_vvlib_without_manifest(invalid_vvlib_name)
with ZipFile(invalid_vvlib_name, "a") as zf:
zf.writestr(vvlib_manifest_name, json.dumps(invalid_vvlib_manifest))

with open(invalid_vvlib_name, "br") as f, self.assertRaises(HTTPException) as e:
self.library_manger.install_library(self.library_uuid, f)
self.assertEqual(e.exception.detail, "指定された音声ライブラリのmanifest_versionが不正です。")

# vvlib_manifestの未対応のmanifest_versionのテスト
invalid_vvlib_manifest = self.create_vvlib_manifest(
manifest_version="999.999.999"
)
self.create_vvlib_without_manifest(invalid_vvlib_name)
with ZipFile(invalid_vvlib_name, "a") as zf:
zf.writestr(vvlib_manifest_name, json.dumps(invalid_vvlib_manifest))

with open(invalid_vvlib_name, "br") as f, self.assertRaises(HTTPException) as e:
self.library_manger.install_library(self.library_uuid, f)
self.assertEqual(e.exception.detail, "指定された音声ライブラリは未対応です。")

# vvlib_manifestのインストール先エンジンの検証のテスト
invalid_vvlib_manifest = self.create_vvlib_manifest(
engine_uuid="26f7823b-20c6-40c5-bf86-6dd5d9d45c18"
)
self.create_vvlib_without_manifest(invalid_vvlib_name)
with ZipFile(invalid_vvlib_name, "a") as zf:
zf.writestr(vvlib_manifest_name, json.dumps(invalid_vvlib_manifest))

with open(invalid_vvlib_name, "br") as f, self.assertRaises(HTTPException) as e:
self.library_manger.install_library(self.library_uuid, f)
self.assertEqual(
e.exception.detail, f"指定された音声ライブラリは{self.engine_name}向けではありません。"
)

# 正しいライブラリをインストールして問題が起きないか
library_path = self.library_manger.install_library(
self.library_uuid, self.library_file
)
self.assertEqual(self.tmp_dir_path / self.library_uuid, library_path)

self.library_manger.uninstall_library(self.library_uuid)

os.remove(invalid_vvlib_name)

def test_uninstall_library(self):
# TODO: アンインストール出来ないライブラリをテストできるようにしたい
with self.assertRaises(HTTPException) as e:
self.library_manger.uninstall_library(self.library_uuid)
self.assertEqual(e.exception.detail, "指定された音声ライブラリはインストールされていません。")

self.library_manger.install_library(self.library_uuid, self.library_file)
self.library_manger.uninstall_library(self.library_uuid)
9 changes: 9 additions & 0 deletions test/vvlib_manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"manifest_version": "0.15.0",
"name": "Test vvlib",
"version": "0.0.1",
"uuid": "2bb8bccf-1c3f-4bc9-959a-f388e37af3ad",
"engine_name": "Test Engine",
"brand_name": "Test",
"engine_uuid": "c7b58856-bd56-4aa1-afb7-b8415f824b06"
}
72 changes: 68 additions & 4 deletions voicevox_engine/downloadable_library.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,38 @@
import base64
import json
import os
import shutil
import zipfile
from io import BytesIO
from pathlib import Path
from typing import Dict

from fastapi import HTTPException
from pydantic import ValidationError
from semver.version import Version

from voicevox_engine.model import DownloadableLibrary, InstalledLibrary
from voicevox_engine.model import DownloadableLibrary, InstalledLibrary, VvlibManifest

__all__ = ["LibraryManager"]

INFO_FILE = "metas.json"


class LibraryManager:
Hiroshiba marked this conversation as resolved.
Show resolved Hide resolved
def __init__(self, library_root_dir: Path):
def __init__(
self,
library_root_dir: Path,
supported_vvlib_version: str,
brand_name: str,
engine_name: str,
engine_uuid: str,
):
self.library_root_dir = library_root_dir
self.library_root_dir.mkdir(exist_ok=True)
self.supported_vvlib_version = Version.parse(supported_vvlib_version)
self.engine_brand_name = brand_name
self.engine_name = engine_name
self.engine_uuid = engine_uuid

def downloadable_libraries(self):
# == ダウンロード情報をネットワーク上から取得する場合
Expand Down Expand Up @@ -64,10 +78,11 @@ def installed_libraries(self) -> Dict[str, InstalledLibrary]:
library = {}
for library_dir in self.library_root_dir.iterdir():
if library_dir.is_dir():
library_uuid = os.path.basename(library_dir)
with open(library_dir / INFO_FILE, encoding="utf-8") as f:
library[library_dir] = json.load(f)
library[library_uuid] = json.load(f)
# アンインストール出来ないライブラリを作る場合、何かしらの条件でFalseを設定する
library[library_dir]["uninstallable"] = True
library[library_uuid]["uninstallable"] = True
return library

def install_library(self, library_id: str, file: BytesIO):
Expand All @@ -81,10 +96,59 @@ def install_library(self, library_id: str, file: BytesIO):
library_dir.mkdir(exist_ok=True)
with open(library_dir / INFO_FILE, "w", encoding="utf-8") as f:
json.dump(library_info, f, indent=4, ensure_ascii=False)
if not zipfile.is_zipfile(file):
raise HTTPException(status_code=422, detail="不正なZIPファイルです。")

with zipfile.ZipFile(file) as zf:
if zf.testzip() is not None:
raise HTTPException(status_code=422, detail="不正なZIPファイルです。")

# validate manifest version
vvlib_manifest = None
try:
vvlib_manifest = json.loads(
zf.read("vvlib_manifest.json").decode("utf-8")
)
except KeyError:
raise HTTPException(
status_code=422, detail="指定された音声ライブラリにvvlib_manifest.jsonが存在しません。"
)
except Exception:
raise HTTPException(
status_code=422, detail="指定された音声ライブラリのvvlib_manifest.jsonは不正です。"
)

try:
VvlibManifest.validate(vvlib_manifest)
except ValidationError:
raise HTTPException(
status_code=422,
detail="指定された音声ライブラリのvvlib_manifest.jsonに不正なデータが含まれています。",
)

if not Version.is_valid(vvlib_manifest["version"]):
raise HTTPException(
status_code=422, detail="指定された音声ライブラリのversionが不正です。"
)

try:
vvlib_manifest_version = Version.parse(
vvlib_manifest["manifest_version"]
)
except ValueError:
raise HTTPException(
status_code=422,
detail="指定された音声ライブラリのmanifest_versionが不正です。",
)

if vvlib_manifest_version > self.supported_vvlib_version:
raise HTTPException(status_code=422, detail="指定された音声ライブラリは未対応です。")

if vvlib_manifest["engine_uuid"] != self.engine_uuid:
raise HTTPException(
status_code=422, detail=f"指定された音声ライブラリは{self.engine_name}向けではありません。"
)

zf.extractall(library_dir)
return library_dir

Expand Down
1 change: 1 addition & 0 deletions voicevox_engine/engine_manifest/EngineManifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,5 @@ class EngineManifest(BaseModel):
terms_of_service: str = Field(title="エンジンの利用規約")
update_infos: List[UpdateInfo] = Field(title="エンジンのアップデート情報")
dependency_licenses: List[LicenseInfo] = Field(title="依存関係のライセンス情報")
supported_vvlib_manifest_version: str = Field(title="エンジンが対応するvvlibのバージョン")
Hiroshiba marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

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

将来エディターとエンジンのバージョンが分かれていって、エンジンのほうがバージョンが低いことがあり得るようになってくる気がしています。(そんなことはないかも・・・?)
このエンジンマニフェストは互換性を保てるように実装していく必要がでてきそうな気がします。

ここはデフォルト値をNoneにするのはどうでしょう?
あるいはこのmanifest_versionを上げるかかなと。

Copy link
Member Author

Choose a reason for hiding this comment

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

エンジンのほうがバージョンが低いことがあり得る

私もあり得ると思っています。

このエンジンマニフェストは互換性を保てるように実装していく

これについては、以前の破壊的変更(supported_featuresmanage_library)にも同様のことが言えると思っていて、同時に修正したいので一旦このPRではOptionalを付けない方針で行きたいと思っています。
どうでしょうか?

Copy link
Member

@Hiroshiba Hiroshiba Jun 17, 2023

Choose a reason for hiding this comment

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

一旦このPRではOptionalを付けない方針

良いと思います!!
(これ系いくつかあるんですが、時間空くので結構忘れちゃいそうなんですよね。。 😇 )

supported_features: SupportedFeatures = Field(title="エンジンが持つ機能")
3 changes: 3 additions & 0 deletions voicevox_engine/engine_manifest/EngineManifestLoader.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ def load_manifest(self) -> EngineManifest:
(self.root_dir / manifest["update_infos"]).read_text("utf-8")
)
],
supported_vvlib_manifest_version=manifest[
"supported_vvlib_manifest_version"
],
dependency_licenses=[
LicenseInfo(**license_info)
for license_info in json.loads(
Expand Down
Loading