[micro_wake_word] Download models in parallel (#17701)

This commit is contained in:
J. Nick Koston
2026-07-21 08:18:15 +12:00
committed by Jesse Hills
parent 231a2897c0
commit b571d2a5ab
4 changed files with 247 additions and 31 deletions
@@ -0,0 +1,169 @@
"""Tests for the micro_wake_word model source validation and downloads."""
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from esphome.components import micro_wake_word as mww
import esphome.config_validation as cv
from esphome.const import (
CONF_FILE,
CONF_MODEL,
CONF_PATH,
CONF_REF,
CONF_TYPE,
CONF_URL,
)
@pytest.fixture
def mock_download_content_many() -> MagicMock:
"""Patch the concurrent download helper so no network is involved."""
with patch(
"esphome.components.micro_wake_word.external_files.download_content_many"
) as m:
yield m
def test_shorthand_model_name_resolves_without_network(
mock_download_content_many: MagicMock,
) -> None:
config = mww._validate_source_shorthand("okay_nabu")
assert config[CONF_TYPE] == mww.TYPE_HTTP
assert config[CONF_URL] == (
"https://github.com/esphome/micro-wake-word-models/raw/main/models/v2/okay_nabu.json"
)
mock_download_content_many.assert_not_called()
def test_shorthand_git_with_ref_not_captured_as_model_name(
setup_core: Path, tmp_path: Path
) -> None:
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
(repo_dir / "model.json").write_text("{}")
with patch(
"esphome.components.micro_wake_word.git.clone_or_update",
return_value=(repo_dir, None),
):
config = mww._validate_source_shorthand("github://user/repo/model.json@main")
assert config[CONF_TYPE] == "git"
assert config[CONF_URL] == "https://github.com/user/repo.git"
assert config[CONF_FILE] == "model.json"
assert config[CONF_REF] == "main"
def test_shorthand_local_path_not_captured_as_model_name(
setup_core: Path, tmp_path: Path
) -> None:
manifest = tmp_path / "model.json"
manifest.write_text("{}")
config = mww.MODEL_SOURCE_SCHEMA(str(manifest))
assert config[CONF_TYPE] == "local"
assert Path(config[CONF_PATH]) == manifest
@pytest.mark.parametrize(
"value", ["some/path/file", "name@ref", "bad:name", "okay_nabu\n", "héllo"]
)
def test_model_name_rejects_non_identifiers(value: str) -> None:
with pytest.raises(cv.Invalid):
mww._validate_source_model_name(value)
def _http_model(name: str) -> dict:
return {
CONF_MODEL: {
CONF_TYPE: mww.TYPE_HTTP,
CONF_URL: f"https://example.com/models/{name}.json",
}
}
def _write_manifest(model_config: dict, contents: str) -> Path:
path = mww._compute_local_file_path(model_config[CONF_MODEL])
path.mkdir(parents=True, exist_ok=True)
manifest = path / "manifest.json"
manifest.write_text(contents)
return path
def test_download_http_models_batches_manifests_then_models(
setup_core: Path, mock_download_content_many: MagicMock
) -> None:
names = ("okay_nabu", "hey_mycroft", "vad")
models = {name: _http_model(name) for name in names}
paths = {
name: _write_manifest(models[name], json.dumps({"model": f"{name}.tflite"}))
for name in names
}
config = {
mww.CONF_MODELS: [
models["okay_nabu"],
models["hey_mycroft"],
# non-http sources must be ignored
{CONF_MODEL: {CONF_TYPE: "local", CONF_PATH: "x"}},
],
mww.CONF_VAD: models["vad"],
}
assert mww._download_http_models(config) is config
assert mock_download_content_many.call_count == 2
manifest_items = list(mock_download_content_many.call_args_list[0].args[0])
assert manifest_items == [
(f"https://example.com/models/{name}.json", paths[name] / "manifest.json")
for name in names
]
model_items = list(mock_download_content_many.call_args_list[1].args[0])
assert model_items == [
(f"https://example.com/models/{name}.tflite", paths[name] / f"{name}.tflite")
for name in names
]
def test_download_http_models_no_http_sources_skips_download(
mock_download_content_many: MagicMock,
) -> None:
config = {mww.CONF_MODELS: [{CONF_MODEL: {CONF_TYPE: "local", CONF_PATH: "x"}}]}
assert mww._download_http_models(config) is config
mock_download_content_many.assert_not_called()
@pytest.mark.parametrize(
("contents", "message"),
[
("not json", "Invalid manifest file"),
("[1, 2]", "must contain a JSON object"),
("{}", "missing the 'model' key"),
],
)
def test_download_http_models_bad_manifest_raises(
setup_core: Path,
mock_download_content_many: MagicMock,
contents: str,
message: str,
) -> None:
model = _http_model("okay_nabu")
config = {mww.CONF_MODELS: [model]}
_write_manifest(model, contents)
with pytest.raises(cv.Invalid, match=message):
mww._download_http_models(config)
# manifests were still fetched in one batch; the model batch never ran
assert mock_download_content_many.call_count == 1
def test_download_http_models_collects_all_manifest_errors(
setup_core: Path, mock_download_content_many: MagicMock
) -> None:
models = {name: _http_model(name) for name in ("one", "two")}
config = {mww.CONF_MODELS: list(models.values())}
_write_manifest(models["one"], "not json")
_write_manifest(models["two"], "[1]")
with pytest.raises(cv.MultipleInvalid) as excinfo:
mww._download_http_models(config)
assert len(excinfo.value.errors) == 2