Files
index-tts/cli_tests/test_cli_v2_config.py
nanaotoandnanaoto bfaf561d7b ci: run the lightweight checks on Windows too (#766)
* test: resolve temp paths in the CLI tests so they pass off Linux

These assertions compare a path the CLI printed against one built from
`tempfile.TemporaryDirectory()`. On Linux those agree; on macOS they do not,
because `/var` is a symlink to `/private/var` and the CLI resolves it:

    AssertionError: 'model_dir = "/var/folders/.../models"'
      not found in 'model_dir = "/private/var/folders/.../models"'

18 of 153 non-GPU tests failed that way. Windows has its own version of the same
problem — `RUNNER~1` 8.3 short names and drive-letter case — so this blocks CI on
any non-Linux runner.

Resolve at the point the temp directory is wrapped, which is what
`test_cli_v2_download.py` already does at its seven sites and why that file was
the only one passing. 129 call sites across the other four files; no behaviour
changes, the comparison just uses the same canonical form on both sides.

153 passed, 0 failed afterwards.

* ci: run the lightweight checks on Windows too

Everything platform-specific in this repo was unverified. `pyproject.toml`
carries three `sys_platform` markers — triton-windows gated to win32, and the
wetext / WeTextProcessing split on linux — and `cli_tests` branches three ways on
`sys.platform` for config and model-cache locations. CI only ever exercised the
Linux side of any of it. The triton-windows marker bug (#742, #749) was exactly
this shape: a Windows-only resolution failure that no amount of green Linux CI
could catch.

No GPU is needed. `-m "not gpu"` deselects the 22 GPU tests, and the one
remaining case that passes `--device cuda` runs against a hand-built
`SimpleNamespace` whose `is_available` is `lambda: True`
(`cli_tests/test_cli_v2_check.py:93-98`), so nothing looks for a real device.

`fail-fast: false` so a Windows failure still lets the Linux result through, and
the uv cache is enabled — the Windows job resolves a cu128 torch and there is no
reason to refetch it every run.

Blocking rather than advisory, because the previous commit fixed the path
assertions that would otherwise have failed there: 153 passed, 0 failed.

---------

Co-authored-by: nanaoto <10inspiral@gmail.com>
2026-08-13 16:33:16 +08:00

595 lines
25 KiB
Python

import contextlib
import io
import importlib
import os
import sys
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
def _mock_optional_dependency_imports():
"""Make flash_attn/triton appear installed so acceleration CLI tests can run."""
original = importlib.import_module
def _fake_import(name, package=None):
if name in ("flash_attn", "triton"):
return mock.MagicMock()
return original(name, package)
return mock.patch("indextts.cli_v2.importlib.import_module", side_effect=_fake_import)
REQUIRED_MODEL_FILES = [
"config.yaml",
"bpe.model",
"gpt.pth",
"s2mel.pth",
"wav2vec2bert_stats.pt",
"feat1.pt",
"feat2.pt",
]
REQUIRED_MODEL_DIRS = [
"qwen0.6bemo4-merge",
]
AUX_MODEL_FILES = [
"hf_cache/semantic_codec_model.safetensors",
"hf_cache/campplus_cn_common.bin",
"hf_cache/bigvgan/config.json",
"hf_cache/bigvgan/bigvgan_generator.pt",
]
AUX_MODEL_DIRS = [
"hf_cache/w2v-bert-2.0",
]
def make_model_dir(path):
path.mkdir(parents=True)
for filename in REQUIRED_MODEL_FILES:
(path / filename).write_text("placeholder", encoding="utf-8")
for dirname in REQUIRED_MODEL_DIRS:
(path / dirname).mkdir()
for filename in AUX_MODEL_FILES:
target = path / filename
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("placeholder", encoding="utf-8")
for dirname in AUX_MODEL_DIRS:
target = path / dirname
target.mkdir(parents=True, exist_ok=True)
(target / "config.json").write_text("placeholder", encoding="utf-8")
def fake_torch():
return SimpleNamespace(
cuda=SimpleNamespace(is_available=lambda: False, device_count=lambda: 0),
xpu=SimpleNamespace(is_available=lambda: False, device_count=lambda: 0),
backends=SimpleNamespace(mps=SimpleNamespace(is_available=lambda: False)),
)
def patched_imports():
real_import_module = importlib.import_module
def import_module(name, package=None):
if name == "torch":
return fake_torch()
if name in {"torchaudio", "indextts"}:
return SimpleNamespace(__name__=name)
return real_import_module(name, package)
return mock.patch("importlib.import_module", side_effect=import_module)
def user_state_paths(temp_path):
if sys.platform == "win32":
return {
"env": {
"APPDATA": str(temp_path / "roaming"),
"LOCALAPPDATA": str(temp_path / "local"),
},
"config_path": temp_path / "roaming" / "IndexTTS" / "config.toml",
"model_dir": temp_path / "local" / "IndexTTS" / "models" / "IndexTTS-2",
}
if sys.platform == "darwin":
app_support = temp_path / "Library" / "Application Support" / "IndexTTS"
return {
"env": {"HOME": str(temp_path)},
"config_path": app_support / "config.toml",
"model_dir": app_support / "models" / "IndexTTS-2",
}
return {
"env": {
"XDG_CONFIG_HOME": str(temp_path / "config"),
"XDG_DATA_HOME": str(temp_path / "data"),
},
"config_path": temp_path / "config" / "indextts" / "config.toml",
"model_dir": temp_path / "data" / "indextts" / "models" / "IndexTTS-2",
}
class ConfigCommandTests(unittest.TestCase):
def setUp(self):
self._import_patch = _mock_optional_dependency_imports()
self._import_patch.start()
def tearDown(self):
self._import_patch.stop()
def run_cli(self, args, **kwargs):
from indextts.cli_v2 import main
stdout = io.StringIO()
stderr = io.StringIO()
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
exit_code = main(args, **kwargs)
return exit_code, stdout.getvalue(), stderr.getvalue()
def test_init_creates_persistent_config_and_default_model_directory_without_model_files(self):
with tempfile.TemporaryDirectory() as temp_dir:
state = user_state_paths(Path(temp_dir).resolve())
with mock.patch.dict(os.environ, state["env"], clear=False):
exit_code, stdout, stderr = self.run_cli(["init"])
config_text = state["config_path"].read_text(encoding="utf-8")
model_dir_files = list(state["model_dir"].iterdir())
self.assertEqual(exit_code, 0)
self.assertIn(f"Config: {state['config_path']}", stdout)
self.assertIn(f"Model directory: {state['model_dir']}", stdout)
self.assertEqual(stderr, "")
self.assertIn(f'model_dir = "{state["model_dir"].as_posix()}"', config_text)
self.assertEqual(model_dir_files, [])
def test_init_with_model_dir_persists_the_requested_model_directory(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
state = user_state_paths(temp_path)
model_dir = temp_path / "custom-models"
with mock.patch.dict(os.environ, state["env"], clear=False):
exit_code, stdout, stderr = self.run_cli(["init", "--model-dir", str(model_dir)])
config_text = state["config_path"].read_text(encoding="utf-8")
model_dir_exists = model_dir.is_dir()
self.assertEqual(exit_code, 0)
self.assertIn(f"Model directory: {model_dir}", stdout)
self.assertEqual(stderr, "")
self.assertTrue(model_dir_exists)
self.assertIn(f'model_dir = "{model_dir.as_posix()}"', config_text)
def test_config_path_prints_the_persistent_config_file_location(self):
with tempfile.TemporaryDirectory() as temp_dir:
state = user_state_paths(Path(temp_dir).resolve())
with mock.patch.dict(os.environ, state["env"], clear=False):
exit_code, stdout, stderr = self.run_cli(["config", "path"])
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"{state['config_path']}\n")
self.assertEqual(stderr, "")
def test_config_set_model_dir_persists_the_model_resource_directory(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
state = user_state_paths(temp_path)
model_dir = temp_path / "persisted-models"
with mock.patch.dict(os.environ, state["env"], clear=False):
exit_code, stdout, stderr = self.run_cli(["config", "set", "model_dir", str(model_dir)])
config_text = state["config_path"].read_text(encoding="utf-8")
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"model_dir = {model_dir}\n")
self.assertEqual(stderr, "")
self.assertIn(f'model_dir = "{model_dir.as_posix()}"', config_text)
def test_config_set_runtime_preferences_persists_device_and_boolean_values(self):
with tempfile.TemporaryDirectory() as temp_dir:
state = user_state_paths(Path(temp_dir).resolve())
with mock.patch.dict(os.environ, state["env"], clear=False):
first = self.run_cli(["config", "set", "default_device", "cuda:0"])
second = self.run_cli(["config", "set", "use_fp16", "true"])
third = self.run_cli(["config", "set", "use_deepspeed", "false"])
fourth = self.run_cli(["config", "set", "use_cuda_kernel", "true"])
fifth = self.run_cli(["config", "set", "use_accel", "true"])
sixth = self.run_cli(["config", "set", "use_torch_compile", "false"])
config_text = state["config_path"].read_text(encoding="utf-8")
self.assertEqual(first, (0, "default_device = cuda:0\n", ""))
self.assertEqual(second, (0, "use_fp16 = true\n", ""))
self.assertEqual(third, (0, "use_deepspeed = false\n", ""))
self.assertEqual(fourth, (0, "use_cuda_kernel = true\n", ""))
self.assertEqual(fifth, (0, "use_accel = true\n", ""))
self.assertEqual(sixth, (0, "use_torch_compile = false\n", ""))
self.assertIn('default_device = "cuda:0"', config_text)
self.assertIn("use_fp16 = true", config_text)
self.assertIn("use_deepspeed = false", config_text)
self.assertIn("use_cuda_kernel = true", config_text)
self.assertIn("use_accel = true", config_text)
self.assertIn("use_torch_compile = false", config_text)
def test_config_set_boolean_preference_rejects_non_boolean_values(self):
with tempfile.TemporaryDirectory() as temp_dir:
state = user_state_paths(Path(temp_dir).resolve())
with mock.patch.dict(os.environ, state["env"], clear=False):
exit_code, stdout, stderr = self.run_cli(["config", "set", "use_fp16", "yes"])
config_exists = state["config_path"].exists()
self.assertEqual(exit_code, 1)
self.assertEqual(stdout, "")
self.assertIn("ERROR: use_fp16 must be true or false", stderr)
self.assertFalse(config_exists)
def test_config_get_prints_the_current_persistent_config(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
state = user_state_paths(temp_path)
model_dir = temp_path / "models"
with mock.patch.dict(os.environ, state["env"], clear=False):
self.run_cli(["config", "set", "model_dir", str(model_dir)])
self.run_cli(["config", "set", "default_device", "cpu"])
exit_code, stdout, stderr = self.run_cli(["config", "get"])
self.assertEqual(exit_code, 0)
self.assertIn(f'model_dir = "{model_dir.as_posix()}"', stdout)
self.assertIn('default_device = "cpu"', stdout)
self.assertEqual(stderr, "")
def test_check_uses_persisted_model_dir_when_command_line_and_environment_do_not_override_it(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
state = user_state_paths(temp_path)
model_dir = temp_path / "persisted-models"
make_model_dir(model_dir)
with mock.patch.dict(os.environ, state["env"], clear=False):
self.run_cli(["config", "set", "model_dir", str(model_dir)])
with patched_imports():
exit_code, stdout, stderr = self.run_cli(["check"])
self.assertEqual(exit_code, 0)
self.assertIn(f"OK: model directory {model_dir}", stdout)
self.assertEqual(stderr, "")
def test_check_model_dir_resolution_prioritizes_command_line_then_environment_then_config(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
state = user_state_paths(temp_path)
cli_model_dir = temp_path / "cli-models"
env_model_dir = temp_path / "env-models"
persisted_model_dir = temp_path / "persisted-models"
for model_dir in [cli_model_dir, env_model_dir, persisted_model_dir]:
make_model_dir(model_dir)
with mock.patch.dict(os.environ, state["env"], clear=False):
self.run_cli(["config", "set", "model_dir", str(persisted_model_dir)])
with patched_imports():
cli_result = self.run_cli(
["check", "--model-dir", str(cli_model_dir)],
)
with mock.patch.dict(os.environ, {"INDEXTTS2_MODEL_DIR": str(env_model_dir)}, clear=False):
with patched_imports():
env_result = self.run_cli(["check"])
with patched_imports():
config_result = self.run_cli(["check"])
self.assertIn(f"OK: model directory {cli_model_dir}", cli_result[1])
self.assertIn(f"OK: model directory {env_model_dir}", env_result[1])
self.assertIn(f"OK: model directory {persisted_model_dir}", config_result[1])
def test_check_initializes_default_state_and_checks_the_platform_default_model_dir(self):
with tempfile.TemporaryDirectory() as temp_dir:
state = user_state_paths(Path(temp_dir).resolve())
with mock.patch.dict(os.environ, state["env"], clear=False):
with mock.patch.dict(os.environ, {"INDEXTTS2_MODEL_DIR": ""}, clear=False):
exit_code, stdout, stderr = self.run_cli(["check"])
config_text = state["config_path"].read_text(encoding="utf-8")
model_dir_exists = state["model_dir"].is_dir()
self.assertEqual(exit_code, 2)
self.assertEqual(stdout, "")
self.assertIn("ERROR: missing required model files", stderr)
self.assertIn(state["model_dir"].as_posix(), config_text)
self.assertTrue(model_dir_exists)
def test_check_with_command_model_dir_still_initializes_default_state_without_persisting_override(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
state = user_state_paths(temp_path)
cli_model_dir = temp_path / "cli-models"
make_model_dir(cli_model_dir)
with mock.patch.dict(os.environ, state["env"], clear=False):
with patched_imports():
exit_code, stdout, stderr = self.run_cli(["check", "--model-dir", str(cli_model_dir)])
config_text = state["config_path"].read_text(encoding="utf-8")
self.assertEqual(exit_code, 0)
self.assertIn(f"OK: model directory {cli_model_dir}", stdout)
self.assertEqual(stderr, "")
self.assertIn(f'model_dir = "{state["model_dir"].as_posix()}"', config_text)
self.assertNotIn(cli_model_dir.as_posix(), config_text)
def test_synth_uses_persisted_model_dir_and_runtime_preferences_by_default(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
state = user_state_paths(temp_path)
model_dir = temp_path / "models"
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
calls = []
make_model_dir(model_dir)
voice_path.write_bytes(b"voice")
class FakeIndexTTS2:
def __init__(self, **kwargs):
calls.append(("init", kwargs))
def infer(self, **kwargs):
calls.append(("infer", kwargs))
with mock.patch.dict(os.environ, state["env"], clear=False):
self.run_cli(["config", "set", "model_dir", str(model_dir)])
self.run_cli(["config", "set", "default_device", "cpu"])
self.run_cli(["config", "set", "use_fp16", "true"])
self.run_cli(["config", "set", "use_deepspeed", "true"])
self.run_cli(["config", "set", "use_cuda_kernel", "true"])
exit_code, stdout, stderr = self.run_cli(
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--output",
str(output_path),
],
tts_factory=FakeIndexTTS2,
)
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {output_path}\n")
self.assertEqual(stderr, "")
self.assertEqual(
calls[0][1],
{
"cfg_path": str(model_dir / "config.yaml"),
"model_dir": str(model_dir),
"use_fp16": True,
"device": "cpu",
"use_cuda_kernel": True,
"use_deepspeed": True,
"use_accel": False,
"use_torch_compile": False,
},
)
def test_batch_uses_persisted_model_dir_and_runtime_preferences_by_default(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
state = user_state_paths(temp_path)
model_dir = temp_path / "models"
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
output_path = temp_path / "out.wav"
calls = []
make_model_dir(model_dir)
voice_path.write_bytes(b"voice")
batch_file.write_text('{"text": "hello", "voice": "voice.wav", "output": "out.wav"}\n', encoding="utf-8")
class FakeIndexTTS2:
def __init__(self, **kwargs):
calls.append(("init", kwargs))
def infer(self, **kwargs):
calls.append(("infer", kwargs))
Path(kwargs["output_path"]).write_bytes(b"audio")
with mock.patch.dict(os.environ, state["env"], clear=False):
self.run_cli(["config", "set", "model_dir", str(model_dir)])
self.run_cli(["config", "set", "default_device", "cpu"])
self.run_cli(["config", "set", "use_fp16", "true"])
self.run_cli(["config", "set", "use_deepspeed", "true"])
self.run_cli(["config", "set", "use_cuda_kernel", "true"])
exit_code, stdout, stderr = self.run_cli(
["batch", "--batch-file", str(batch_file)],
tts_factory=FakeIndexTTS2,
)
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {output_path}\nBatch complete: 1 tasks generated\n")
self.assertEqual(stderr, "")
self.assertEqual(
calls[0][1],
{
"cfg_path": str(model_dir / "config.yaml"),
"model_dir": str(model_dir),
"use_fp16": True,
"device": "cpu",
"use_cuda_kernel": True,
"use_deepspeed": True,
"use_accel": False,
"use_torch_compile": False,
},
)
def test_batch_command_line_can_disable_persisted_boolean_runtime_preferences_for_one_run(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
state = user_state_paths(temp_path)
model_dir = temp_path / "models"
voice_path = temp_path / "voice.wav"
batch_file = temp_path / "batch.jsonl"
calls = []
make_model_dir(model_dir)
voice_path.write_bytes(b"voice")
batch_file.write_text('{"text": "hello", "voice": "voice.wav", "output": "out.wav"}\n', encoding="utf-8")
class FakeIndexTTS2:
def __init__(self, **kwargs):
calls.append(("init", kwargs))
def infer(self, **kwargs):
calls.append(("infer", kwargs))
Path(kwargs["output_path"]).write_bytes(b"audio")
with mock.patch.dict(os.environ, state["env"], clear=False):
self.run_cli(["config", "set", "model_dir", str(model_dir)])
self.run_cli(["config", "set", "use_fp16", "true"])
self.run_cli(["config", "set", "use_deepspeed", "true"])
self.run_cli(["config", "set", "use_cuda_kernel", "true"])
before_config = state["config_path"].read_text(encoding="utf-8")
exit_code, stdout, stderr = self.run_cli(
[
"batch",
"--batch-file",
str(batch_file),
"--no-fp16",
"--no-deepspeed",
"--no-cuda-kernel",
"--no-accel",
"--no-torch-compile",
],
tts_factory=FakeIndexTTS2,
)
after_config = state["config_path"].read_text(encoding="utf-8")
self.assertEqual(exit_code, 0)
self.assertIn("Batch complete: 1 tasks generated", stdout)
self.assertEqual(stderr, "")
self.assertEqual(before_config, after_config)
self.assertEqual(calls[0][1]["use_fp16"], False)
self.assertEqual(calls[0][1]["use_deepspeed"], False)
self.assertEqual(calls[0][1]["use_cuda_kernel"], False)
self.assertEqual(calls[0][1]["use_accel"], False)
self.assertEqual(calls[0][1]["use_torch_compile"], False)
def test_synth_command_line_overrides_do_not_rewrite_persistent_config(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
state = user_state_paths(temp_path)
persisted_model_dir = temp_path / "persisted-models"
cli_model_dir = temp_path / "cli-models"
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
calls = []
make_model_dir(persisted_model_dir)
make_model_dir(cli_model_dir)
voice_path.write_bytes(b"voice")
class FakeIndexTTS2:
def __init__(self, **kwargs):
calls.append(("init", kwargs))
def infer(self, **kwargs):
calls.append(("infer", kwargs))
with mock.patch.dict(os.environ, state["env"], clear=False):
self.run_cli(["config", "set", "model_dir", str(persisted_model_dir)])
self.run_cli(["config", "set", "default_device", "cpu"])
self.run_cli(["config", "set", "use_fp16", "false"])
before_config = state["config_path"].read_text(encoding="utf-8")
exit_code, stdout, stderr = self.run_cli(
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--output",
str(output_path),
"--model-dir",
str(cli_model_dir),
"--device",
"cuda:0",
"--fp16",
"--deepspeed",
"--cuda-kernel",
"--accel",
"--torch-compile",
],
tts_factory=FakeIndexTTS2,
)
after_config = state["config_path"].read_text(encoding="utf-8")
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {output_path}\n")
self.assertEqual(stderr, "")
self.assertEqual(before_config, after_config)
self.assertEqual(calls[0][1]["model_dir"], str(cli_model_dir))
self.assertEqual(calls[0][1]["device"], "cuda:0")
self.assertEqual(calls[0][1]["use_fp16"], True)
self.assertEqual(calls[0][1]["use_deepspeed"], True)
self.assertEqual(calls[0][1]["use_cuda_kernel"], True)
self.assertEqual(calls[0][1]["use_accel"], True)
self.assertEqual(calls[0][1]["use_torch_compile"], True)
def test_synth_command_line_can_disable_persisted_boolean_runtime_preferences_for_one_run(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir).resolve()
state = user_state_paths(temp_path)
model_dir = temp_path / "models"
voice_path = temp_path / "voice.wav"
output_path = temp_path / "out.wav"
calls = []
make_model_dir(model_dir)
voice_path.write_bytes(b"voice")
class FakeIndexTTS2:
def __init__(self, **kwargs):
calls.append(("init", kwargs))
def infer(self, **kwargs):
calls.append(("infer", kwargs))
with mock.patch.dict(os.environ, state["env"], clear=False):
self.run_cli(["config", "set", "model_dir", str(model_dir)])
self.run_cli(["config", "set", "use_fp16", "true"])
self.run_cli(["config", "set", "use_deepspeed", "true"])
self.run_cli(["config", "set", "use_cuda_kernel", "true"])
before_config = state["config_path"].read_text(encoding="utf-8")
exit_code, stdout, stderr = self.run_cli(
[
"synth",
"--text",
"hello",
"--voice",
str(voice_path),
"--output",
str(output_path),
"--no-fp16",
"--no-deepspeed",
"--no-cuda-kernel",
"--no-accel",
"--no-torch-compile",
],
tts_factory=FakeIndexTTS2,
)
after_config = state["config_path"].read_text(encoding="utf-8")
self.assertEqual(exit_code, 0)
self.assertEqual(stdout, f"Generated: {output_path}\n")
self.assertEqual(stderr, "")
self.assertEqual(before_config, after_config)
self.assertEqual(calls[0][1]["use_fp16"], False)
self.assertEqual(calls[0][1]["use_deepspeed"], False)
self.assertEqual(calls[0][1]["use_cuda_kernel"], False)
self.assertEqual(calls[0][1]["use_accel"], False)
self.assertEqual(calls[0][1]["use_torch_compile"], False)
if __name__ == "__main__":
unittest.main()