Run the test suite under pytest-xdist, and fix the isolation bugs it exposed (#171) (#178)

* test: run the suite under pytest-xdist and fix the isolation bugs that exposed (#171)

* test: address review — cgroup+affinity-aware worker count, dedupe cuda_available, close remaining isolation leaks (#171)

* test: fix cpu_topology cross-platform breakage, cover the fallback branches, correct CONTRIBUTING (#171)

* test: -n auto no longer returns a single worker on one physical core (#171)
This commit is contained in:
veguAI
2026-07-29 14:35:56 +03:00
committed by GitHub
parent 70011a4bd8
commit ba4cfc0e61
16 changed files with 810 additions and 173 deletions

View File

@@ -59,6 +59,28 @@ Ensure all tests pass by running:
uv run pytest tests/ -p no:warnings
```
The test dependencies live in the `dev` extra, which is not installed by
default — install it once with either of:
```bash
uv sync --extra dev
uv pip install -e ".[dev]"
```
Plain `uv sync` does not just skip the extra, it *removes* it: run against a
working environment it uninstalls `pytest`, `pytest-xdist` and `pytest-asyncio`,
breaking the command above.
The suite runs distributed across your CPU cores by default (via `pytest-xdist`).
If your environment predates that — pytest installed, `pytest-xdist` not — pytest
exits with `error: unrecognized arguments: -n --dist worksteal`; install the `dev`
extra as above.
Workers discard `-s` / `--capture=no` **stdout** entirely (stderr, including
`logging`, still comes through but out of order), and failures are reordered.
Pass `-n0` to run serially while debugging a specific test:
```bash
uv run pytest tests/ -n0 -s -x
```
## Questions?
If you're unsure whether your contribution would be welcome, please open an issue to discuss it first. This saves everyone time and ensures alignment with the project's direction.

View File

@@ -80,6 +80,7 @@ dev = [
"pytest>=6.2",
"pytest-asyncio>=0.25.3",
"pytest-cov>=4.0",
"pytest-xdist>=3.8",
"mypy>=0.910",
"mkdocs-material>=9.5.27",
"mkdocs-awesome-pages-plugin>=2.9.2",
@@ -155,6 +156,13 @@ explicit = true
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
# Distribute across cores by default. The worker count comes from
# `pytest_xdist_auto_num_workers` in tests/conftest.py, which respects CPU
# affinity and any cgroup quota, and falls back to in-process execution on a
# single CPU. `-n0` runs serially, which is what you want for `-s` /
# `--capture=no` or readable output. (`--pdb` needs no flag — xdist disables
# distribution for it by itself.)
addopts = "-n auto --dist worksteal"
[tool.coverage.run]
source = ["src/talemate"]

View File

@@ -7,24 +7,13 @@ import structlog
import pydantic
from pydantic import ConfigDict
import torch
# Lazy imports for heavy dependencies
def _import_heavy_deps():
global ta, ChatterboxTTS
import torchaudio as ta
from chatterbox.tts import ChatterboxTTS
CUDA_AVAILABLE = torch.cuda.is_available()
from talemate.agents.base import (
AgentAction,
AgentActionConfig,
AgentDetail,
)
from talemate.ux.schema import Field
from talemate.util.gpu import cuda_available
from .schema import Voice, Chunk, GenerationContext, VoiceProvider, INFO_CHUNK_SIZE
from .voice_library import add_default_voices
@@ -33,6 +22,15 @@ from .util import voice_is_talemate_asset
log = structlog.get_logger("talemate.agents.tts.chatterbox")
def _import_heavy_deps():
# torchaudio and the chatterbox model stack are only needed once a
# generation actually runs.
global ta, ChatterboxTTS
import torchaudio as ta
from chatterbox.tts import ChatterboxTTS
add_default_voices(
[
Voice(
@@ -162,7 +160,7 @@ class ChatterboxMixin:
config={
"device": AgentActionConfig(
type="text",
value="cuda" if CUDA_AVAILABLE else "cpu",
value="cuda" if cuda_available() else "cpu",
label="Device",
choices=[
{"value": "cpu", "label": "CPU"},

View File

@@ -8,23 +8,13 @@ import pydantic
from pydantic import ConfigDict
import re
import torch
# Lazy imports for heavy dependencies
def _import_heavy_deps():
global F5TTS
from f5_tts.api import F5TTS
CUDA_AVAILABLE = torch.cuda.is_available()
from talemate.agents.base import (
AgentAction,
AgentActionConfig,
AgentDetail,
)
from talemate.ux.schema import Field
from talemate.util.gpu import cuda_available
from .schema import Voice, Chunk, GenerationContext, VoiceProvider, INFO_CHUNK_SIZE
from .voice_library import add_default_voices
@@ -33,6 +23,13 @@ from .util import voice_is_talemate_asset
log = structlog.get_logger("talemate.agents.tts.f5tts")
def _import_heavy_deps():
# The f5_tts model stack is only needed once a generation actually runs.
global F5TTS
from f5_tts.api import F5TTS
REF_TEXT = "You awaken aboard your ship, the Starlight Nomad. A soft hum resonates throughout the vessel indicating its systems are online."
add_default_voices(
@@ -202,7 +199,7 @@ class F5TTSMixin:
config={
"device": AgentActionConfig(
type="text",
value="cuda" if CUDA_AVAILABLE else "cpu",
value="cuda" if cuda_available() else "cpu",
label="Device",
choices=[
{"value": "cpu", "label": "CPU"},

View File

@@ -9,12 +9,6 @@ from pydantic import ConfigDict
import traceback
from pathlib import Path
import torch
import soundfile as sf
from kokoro import KPipeline
from talemate.agents.base import (
AgentAction,
AgentActionConfig,
@@ -32,6 +26,16 @@ from .voice_library import add_default_voices
log = structlog.get_logger("talemate.agents.tts.kokoro")
def _import_heavy_deps():
# kokoro pulls in torch and costs ~4.3s / ~843MB, so it is loaded on first
# generation rather than at import.
global torch, sf, KPipeline
import torch
import soundfile as sf
from kokoro import KPipeline
CUSTOM_VOICE_STORAGE = (
Path(__file__).parent.parent.parent.parent.parent / "tts" / "voice" / "kokoro"
)
@@ -207,6 +211,7 @@ class KokoroMixin:
pass
def _kokoro_mix(self, mixer: VoiceMixer) -> "torch.Tensor":
_import_heavy_deps()
pipeline = KPipeline(lang_code="a")
packs = [
@@ -230,6 +235,7 @@ class KokoroMixin:
async def kokoro_test_mix(self, mixer: VoiceMixer):
"""Test a mixed voice by generating a sample."""
_import_heavy_deps()
mixed_voice_tensor = self._kokoro_mix(mixer)
loop = asyncio.get_event_loop()
@@ -257,6 +263,7 @@ class KokoroMixin:
async def kokoro_save_mix(self, voice_id: str, mixer: VoiceMixer) -> Path:
"""Save a voice tensor to disk."""
_import_heavy_deps()
# Ensure the directory exists
CUSTOM_VOICE_STORAGE.mkdir(parents=True, exist_ok=True)
@@ -273,6 +280,7 @@ class KokoroMixin:
file_path: str,
) -> None:
"""Generate audio from text using the given voice."""
_import_heavy_deps()
try:
generator = pipeline(text, voice=voice)
for i, (gs, ps, audio) in enumerate(generator):
@@ -295,7 +303,7 @@ class KokoroMixin:
log.debug(
"kokoro - reinitializing tts instance",
)
# Lazy import heavy dependencies only when needed
_import_heavy_deps()
self.kokoro_instance = KokoroInstance(
# a= American English

View File

@@ -8,7 +8,6 @@ from pathlib import Path
import numpy as np
import pydantic
import structlog
import torch
from huggingface_hub import get_token
from pydantic import ConfigDict
from talemate.agents.base import AgentAction, AgentActionConfig, AgentDetail
@@ -23,8 +22,9 @@ log = structlog.get_logger("talemate.agents.tts.pocket_tts")
def _import_heavy_deps():
global sf, TTSModel
global sf, torch, TTSModel
import soundfile as sf
import torch
from pocket_tts import TTSModel

View File

@@ -5,7 +5,25 @@ Kept framework-agnostic at the import boundary: torch is imported lazily so the
rest of the application keeps working on installs without a CUDA-enabled torch.
"""
__all__ = ["release_cuda_cache"]
import functools
__all__ = ["cuda_available", "release_cuda_cache"]
@functools.cache
def cuda_available() -> bool:
"""
Whether torch reports a usable CUDA device.
Importing torch costs ~1.2s and ~470MB, hence the deferred import. False
when torch is absent or unusable, matching the rest of this module.
"""
try:
import torch
except ImportError:
return False
return torch.cuda.is_available()
def release_cuda_cache() -> bool:
@@ -22,13 +40,10 @@ def release_cuda_cache() -> bool:
Returns True if a CUDA cache flush was performed, False otherwise (no torch,
or no CUDA device).
"""
try:
import torch
except ImportError:
if not cuda_available():
return False
if not torch.cuda.is_available():
return False
import torch
torch.cuda.empty_cache()
return True

View File

@@ -6,6 +6,7 @@ multiple test modules (test_graphs, test_layered_history, etc.).
"""
import contextvars
import os
from collections import deque
from pathlib import Path
@@ -25,6 +26,108 @@ from talemate.tale_mate import Scene
# Root of the repository (where config.example.yaml lives)
_REPO_ROOT = Path(__file__).resolve().parent.parent
# ---------------------------------------------------------------------------
# Parallel execution (pytest-xdist)
# ---------------------------------------------------------------------------
# Beyond this, extra workers stop paying: each one re-imports talemate (~5s and
# a few hundred MB), so the fixed cost grows while the shared work per worker
# shrinks. Raise it if you have the cores and the RAM to spare.
#
# Capping here rather than with xdist's `--maxprocesses` is deliberate: that
# flag is applied to *any* worker count (xdist/plugin.py:321-323), so putting it
# in addopts would silently clamp an explicit `-n 16` too. This only shapes the
# `auto` default and leaves an explicit `-n` alone.
MAX_TEST_WORKERS = 8
_CGROUP_V2_CPU_MAX = Path("/sys/fs/cgroup/cpu.max")
_CGROUP_V1_CPU_QUOTA = Path("/sys/fs/cgroup/cpu/cpu.cfs_quota_us")
_CGROUP_V1_CPU_PERIOD = Path("/sys/fs/cgroup/cpu/cpu.cfs_period_us")
def _cgroup_cpu_quota() -> float | None:
"""CPU quota this process is allowed, or None when unrestricted.
Containers routinely expose every host core through ``os.cpu_count()``
while the cgroup allows a fraction of one. Without this, ``-n auto`` sizes
the pool from a number the scheduler will never honour.
"""
try:
quota, period = _CGROUP_V2_CPU_MAX.read_text().split()
if quota != "max":
return int(quota) / int(period)
return None
except (OSError, ValueError):
pass
try:
quota = int(_CGROUP_V1_CPU_QUOTA.read_text())
period = int(_CGROUP_V1_CPU_PERIOD.read_text())
if quota > 0:
return quota / period
except (OSError, ValueError):
pass
return None
def _usable_cpus() -> int:
"""CPUs this process can actually run on.
Affinity and CFS quota are independent restrictions — ``--cpuset-cpus``,
Kubernetes' static CPU manager, ``taskset`` and Slurm all pin cores without
setting a quota, while a quota can apply with every core visible. Both have
to be consulted; ``os.cpu_count()`` alone sees through neither.
"""
if hasattr(os, "sched_getaffinity"):
cpus = len(os.sched_getaffinity(0))
else:
cpus = os.cpu_count() or 1
quota = _cgroup_cpu_quota()
if quota is not None:
cpus = min(cpus, int(quota))
return cpus
def pytest_xdist_auto_num_workers(config):
"""Worker count for ``-n auto`` / ``-n logical``.
xdist's own implementation prefers ``psutil.cpu_count()``, which sees
neither cgroup quota nor CPU affinity, and cannot return 0. This one does,
and 0 is meaningful: it makes xdist run in-process, which is the right
answer on a single usable CPU, where a second interpreter plus per-test IPC
is pure cost.
The hookspec is ``firstresult``, and conftest implementations run ahead of
plugin ones, so returning None here is how xdist's own handling is allowed
to take over.
"""
if os.environ.get("PYTEST_XDIST_AUTO_NUM_WORKERS"):
# xdist's documented override, and only its own implementation reads it.
return None
cpus = _usable_cpus()
# `-n logical` asks for hyperthreads, `-n auto` for physical cores. Without
# psutil there is no way to tell them apart, so the affinity count (which is
# logical) stands in for both.
if config.option.numprocesses != "logical":
try:
import psutil
except ImportError:
pass
else:
physical = psutil.cpu_count(logical=False)
if physical:
cpus = min(cpus, physical)
# Checked after the clamp, not before: a single physical core behind two
# hyperthreads would otherwise pass this and then be clamped to 1, and one
# worker pays the whole IPC and startup bill for no parallelism at all.
if cpus < 2:
return 0
return min(cpus, MAX_TEST_WORKERS)
@pytest.fixture(autouse=True, scope="session")
def _use_example_config():
@@ -74,6 +177,80 @@ class MockClientContext:
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def restore_signal_receivers():
"""Undo signal connections a test leaves behind.
``async_signals.handlers`` is process-wide, and agents connect to it in
their constructor (``MemoryAgent.__init__`` connects ``config.changed``),
so anything that instantiates agents — ``bootstrap_engine()`` below, most
of all — permanently attaches a receiver bound to a throwaway agent. Later
tests then dispatch into dead objects: emitting ``config.changed`` after a
bootstrap used to raise ``AttributeError`` inside ``MockMemoryAgent``.
Snapshotting per test keeps that from leaking, and keeps test order from
deciding which receivers are attached.
The invariant this rests on: **any module that connects at import time must
already be imported when the first snapshot is taken** — module-level code
never runs twice, so a receiver connected by a module first imported inside
a test body is wiped at that test's teardown and silently gone for the rest
of the worker's session. Every such module today (``talemate.instance``,
``scene_assets``, ``client.openrouter``, ``client.pi_bridge``) is pulled in
by this conftest's own imports, and test modules are imported at collection.
Never import one for the first time inside a test body.
``tests/test_global_state_isolation.py`` checks that those modules really
are imported before the tests run. It deliberately lives there rather than
as an assertion here: anything raised before the ``yield`` aborts setup, so
a single leaky test would error every later test in the worker instead of
failing once with a usable message.
"""
snapshot = {
name: list(signal.receivers) for name, signal in async_signals.handlers.items()
}
yield
for name, signal in async_signals.handlers.items():
# Signals registered during the test start out with no receivers, so
# clearing is the correct restore for them too.
signal.receivers[:] = snapshot.get(name, [])
@pytest.fixture(autouse=True)
def restore_global_registries():
"""Undo writes to the process-wide agent and voice registries.
``bootstrap_engine()`` fills ``instance.AGENTS`` with throwaway agents and
swaps ``voice_library.VOICE_LIBRARY`` for an empty one, and several tests
rebind ``VOICE_LIBRARY`` directly. None of it is restored by the callers, so
without this the last test to bootstrap decides what every later test in the
worker sees.
``AGENTS`` is restored in place: ``talemate/server/agent_config.py`` and
friends do ``from talemate.instance import AGENTS``, so rebinding it hands
them a stale dict — the exact defect this suite already tripped over.
"""
agents_snapshot = dict(instance.AGENTS)
voice_library_snapshot = voice_library.VOICE_LIBRARY
yield
instance.AGENTS.clear()
instance.AGENTS.update(agents_snapshot)
voice_library.VOICE_LIBRARY = voice_library_snapshot
@pytest.fixture
def template_dir(tmp_path) -> str:
"""A world-state template directory of this test's own.
Group/Collection operations write real YAML, so tests sharing one directory
cannot run concurrently — and a shared directory that each test wipes on
entry cannot survive being distributed across workers.
"""
path = tmp_path / "templates"
path.mkdir()
return str(path)
@pytest.fixture
def isolate_signals():
"""Factory that clears a signal's receivers for the duration of the test

View File

@@ -164,7 +164,9 @@ class TestAssignVoiceToCharacterEarlyReturn:
assert result is None # early return (no calls list)
@pytest.mark.asyncio
async def test_skipped_when_no_voices_available(self, scene, director, tts_agent):
async def test_skipped_when_no_voices_available(
self, scene, director, tts_agent, monkeypatch
):
# Force should_assign_voice to True and have ready APIs, but no voices
# in the global library or the scene library.
with patch.object(type(tts_agent), "enabled", property(lambda self: True)):
@@ -174,8 +176,10 @@ class TestAssignVoiceToCharacterEarlyReturn:
property(lambda self: ["someapi"]),
):
# Replace the voice library with an empty one
voice_library_mod.VOICE_LIBRARY = voice_library_mod.VoiceLibrary(
voices={}
monkeypatch.setattr(
voice_library_mod,
"VOICE_LIBRARY",
voice_library_mod.VoiceLibrary(voices={}),
)
# Scene's voice_library may be empty by default — ensure so
scene.voice_library = voice_library_mod.VoiceLibrary(voices={})
@@ -199,8 +203,10 @@ class TestAssignVoiceToCharacterWithVoices:
):
# Stand up a global voice library with one Voice
v = Voice(label="V", provider="someapi", provider_id="v1")
voice_library_mod.VOICE_LIBRARY = voice_library_mod.VoiceLibrary(
voices={v.id: v}
monkeypatch.setattr(
voice_library_mod,
"VOICE_LIBRARY",
voice_library_mod.VoiceLibrary(voices={v.id: v}),
)
scene.voice_library = voice_library_mod.VoiceLibrary(voices={})

View File

@@ -0,0 +1,176 @@
"""Regression tests for the conftest fixtures that keep process-wide state
from leaking between tests.
The suite runs distributed, so test order is no longer fixed — and with
``--dist worksteal`` two tests from this very file can land on different
workers. So none of these tests may depend on another having run first: each
drives the fixture's own generator (``__wrapped__`` is the undecorated
function) through setup and teardown, and checks the restore directly.
"""
import sys
import pytest
import talemate.agents.tts.voice_library as voice_library
import talemate.emit.async_signals as async_signals
import talemate.instance as instance
import talemate.server.agent_config as agent_config
from talemate.agents.tts.schema import Voice
import conftest
from conftest import bootstrap_engine
def run_fixture(fixture):
"""Return a context manager that drives a generator fixture by hand."""
class _Driver:
def __enter__(self):
self.gen = fixture.__wrapped__()
return next(self.gen)
def __exit__(self, *exc):
with pytest.raises(StopIteration):
next(self.gen)
return False
return _Driver()
class TestAgentsRegistryIdentity:
def test_modules_that_imported_agents_by_name_see_the_same_dict(self):
"""``instance.AGENTS`` must never be rebound.
``talemate/server/agent_config.py`` does ``from talemate.instance
import AGENTS``, binding the dict object itself. Restoring the registry
by assigning a *new* dict leaves that module pointing at the old one,
and it silently stops seeing agents anything else registers — the
original defect this suite tripped over.
"""
assert agent_config.AGENTS is instance.AGENTS
def test_registry_writes_are_visible_through_the_by_name_import(self):
sentinel = object()
instance.AGENTS["isolation-probe"] = sentinel
try:
assert agent_config.AGENTS.get("isolation-probe") is sentinel
finally:
instance.AGENTS.pop("isolation-probe", None)
class TestRestoreGlobalRegistries:
"""``bootstrap_engine()`` writes to two process-wide registries and
restores neither; the autouse fixture is what puts them back."""
def test_bootstrap_engine_agents_are_rolled_back(self):
with run_fixture(conftest.restore_global_registries):
bootstrap_engine()
assert "director" in instance.AGENTS
assert "director" not in instance.AGENTS
def test_agents_registry_is_restored_in_place_not_rebound(self):
before = instance.AGENTS
with run_fixture(conftest.restore_global_registries):
bootstrap_engine()
assert instance.AGENTS is before
assert agent_config.AGENTS is instance.AGENTS
def test_voice_library_rebind_is_rolled_back(self):
before = voice_library.VOICE_LIBRARY
probe = Voice(label="Probe", provider="probe", provider_id="p1")
with run_fixture(conftest.restore_global_registries):
voice_library.VOICE_LIBRARY = voice_library.VoiceLibrary(
voices={probe.id: probe}
)
assert probe.id in voice_library.VOICE_LIBRARY.voices
assert voice_library.VOICE_LIBRARY is before
class TestSignalSnapshotInvariant:
"""``restore_signal_receivers`` restores to a snapshot taken at test setup,
so a module that connects at *import* time and is first imported inside a
test body has its receiver wiped at that test's teardown — permanently, and
silently, since module-level code never runs twice."""
# Every module in `src/` with a module-level `.connect(`. Add to this when
# a new one appears; the failure it guards against is otherwise invisible.
CONNECTING_MODULES = {
"talemate.instance",
"talemate.scene_assets",
"talemate.client.openrouter",
"talemate.client.pi_bridge",
}
def test_import_time_connectors_are_imported_before_tests_run(self):
missing = self.CONNECTING_MODULES - sys.modules.keys()
assert not missing, (
f"{sorted(missing)} connect to signals at import time but are not "
"imported by tests/conftest.py. Whichever test imports one first "
"will have its receiver discarded at teardown."
)
class TestRestoreSignalReceivers:
def test_receiver_connected_during_a_test_is_disconnected(self):
signal = async_signals.get("config.changed")
async def handler(emission):
pass
with run_fixture(conftest.restore_signal_receivers):
signal.connect(handler)
assert handler in signal.receivers
assert handler not in signal.receivers
def test_import_time_receivers_are_preserved(self):
"""Restoring to a snapshot must keep receivers connected at module
import — wiping those would disable real application behaviour for the
rest of the worker's session, silently."""
signal = async_signals.get("config.changed")
with run_fixture(conftest.restore_signal_receivers):
pass
assert instance.on_config_changed in signal.receivers
def test_receiver_list_identity_is_preserved(self):
"""``AsyncSignal.receivers`` is handed out by reference, so the restore
has to mutate the list rather than rebind it."""
signal = async_signals.get("config.changed")
receivers = signal.receivers
with run_fixture(conftest.restore_signal_receivers):
pass
assert signal.receivers is receivers
def test_signal_registered_during_a_test_is_left_with_no_receivers(self):
with run_fixture(conftest.restore_signal_receivers):
async_signals.register("isolation.probe.signal")
signal = async_signals.get("isolation.probe.signal")
async def handler(emission):
pass
signal.connect(handler)
assert signal.receivers == [handler]
assert async_signals.get("isolation.probe.signal").receivers == []
class TestTemplateDirFixture:
"""The world-state template tests used to share one directory that each of
them wiped on entry — safe sequentially, a race once distributed."""
def test_directory_is_empty_and_unique(self, template_dir, tmp_path):
import os
assert os.path.isdir(template_dir)
assert not os.listdir(template_dir)
assert str(tmp_path) in template_dir

View File

@@ -1,5 +1,6 @@
import os
import json
import shutil
import pytest
import enum
import pydantic
@@ -44,7 +45,7 @@ def mock_scene():
@pytest.fixture
def mock_scene_with_assets():
def mock_scene_with_assets(tmp_path):
scene = MockScene()
bootstrap_scene(scene)
@@ -55,8 +56,13 @@ def mock_scene_with_assets():
with open(test_scene_path, "r") as f:
test_scene_data = json.load(f)
# Override scenes_dir to point to test data directory
test_scenes_dir = os.path.join(BASE_DIR, "data", "scenes")
# Work against a copy: the fixture writes library.json, and the source tree
# copy is git-tracked and shared by every worker.
test_scenes_dir = os.path.join(tmp_path, "scenes")
shutil.copytree(
os.path.join(BASE_DIR, "data", "scenes"),
test_scenes_dir,
)
scene.scenes_dir = lambda: test_scenes_dir
scene.project_name = "talemate-laboratory"

View File

@@ -899,8 +899,11 @@ async def generate_agent(tts_agent, fresh_voice_library, monkeypatch):
yield tts_agent
# restore
instance.AGENTS = original_agents
# restore in place — modules that did `from talemate.instance import AGENTS`
# hold a reference to this dict, so rebinding it would leave them looking at
# a stale copy for the rest of the session.
instance.AGENTS.clear()
instance.AGENTS.update(original_agents)
async def _drain_queue(agent: TTSAgent, timeout: float = 1.0):

View File

@@ -1,11 +1,11 @@
"""
Integration tests for world state template Group and Collection file operations.
Tests actual YAML file creation, loading, updating, and deletion under tests/data/templates/.
Tests actual YAML file creation, loading, updating, and deletion in a per-test
temporary template directory.
"""
import os
import shutil
import pytest
import yaml
@@ -17,18 +17,6 @@ from talemate.world_state.templates.base import (
)
from talemate.world_state.templates.state_reinforcement import StateReinforcement
TEMPLATE_TEST_PATH = os.path.join(os.path.dirname(__file__), "data", "templates")
@pytest.fixture(autouse=True)
def clean_template_dir():
"""Ensure a clean template directory before and after each test."""
if os.path.exists(TEMPLATE_TEST_PATH):
shutil.rmtree(TEMPLATE_TEST_PATH)
os.makedirs(TEMPLATE_TEST_PATH, exist_ok=True)
yield
shutil.rmtree(TEMPLATE_TEST_PATH)
def make_template(**overrides) -> dict:
"""Create a state_reinforcement template dict with sensible defaults."""
@@ -59,35 +47,35 @@ def make_group(name="Test Group", templates=None, **overrides) -> Group:
class TestGroupSaveAndLoad:
def test_save_creates_yaml_file(self):
def test_save_creates_yaml_file(self, template_dir):
group = make_group()
group.save(TEMPLATE_TEST_PATH)
group.save(template_dir)
assert group.path is not None
assert os.path.exists(group.path)
assert group.path.endswith(".yaml")
def test_save_sets_path_on_group(self):
def test_save_sets_path_on_group(self, template_dir):
group = make_group()
assert group.path is None
group.save(TEMPLATE_TEST_PATH)
group.save(template_dir)
expected = os.path.join(TEMPLATE_TEST_PATH, "test-group.yaml")
expected = os.path.join(template_dir, "test-group.yaml")
assert group.path == expected
def test_save_does_not_overwrite_existing_path(self):
def test_save_does_not_overwrite_existing_path(self, template_dir):
group = make_group()
group.save(TEMPLATE_TEST_PATH)
group.save(template_dir)
original_path = group.path
# Saving again should reuse the same path
group.save(TEMPLATE_TEST_PATH)
group.save(template_dir)
assert group.path == original_path
def test_saved_yaml_contains_group_data(self):
def test_saved_yaml_contains_group_data(self, template_dir):
group = make_group(name="My Group", author="Alice", description="desc")
group.save(TEMPLATE_TEST_PATH)
group.save(template_dir)
with open(group.path, "r") as f:
data = yaml.safe_load(f)
@@ -98,10 +86,10 @@ class TestGroupSaveAndLoad:
assert data["uid"] == group.uid
assert "path" not in data # path should be excluded from YAML
def test_saved_yaml_contains_templates(self):
def test_saved_yaml_contains_templates(self, template_dir):
tmpl = StateReinforcement(**make_template(name="Mood Check"))
group = make_group(templates=[tmpl])
group.save(TEMPLATE_TEST_PATH)
group.save(template_dir)
with open(group.path, "r") as f:
data = yaml.safe_load(f)
@@ -110,10 +98,10 @@ class TestGroupSaveAndLoad:
assert data["templates"][tmpl.uid]["name"] == "Mood Check"
assert data["templates"][tmpl.uid]["query"] == "What is the character's mood?"
def test_load_roundtrip(self):
def test_load_roundtrip(self, template_dir):
tmpl = StateReinforcement(**make_template(name="Roundtrip"))
group = make_group(name="Roundtrip Group", templates=[tmpl])
group.save(TEMPLATE_TEST_PATH)
group.save(template_dir)
loaded = Group.load(group.path)
@@ -124,11 +112,11 @@ class TestGroupSaveAndLoad:
assert tmpl.uid in loaded.templates
assert loaded.templates[tmpl.uid].name == "Roundtrip"
def test_save_assigns_group_uid_to_templates(self):
def test_save_assigns_group_uid_to_templates(self, template_dir):
tmpl = StateReinforcement(**make_template())
tmpl.group = None # explicitly unset
group = make_group(templates=[tmpl])
group.save(TEMPLATE_TEST_PATH)
group.save(template_dir)
assert tmpl.group == group.uid
@@ -137,9 +125,9 @@ class TestGroupSaveAndLoad:
class TestGroupDelete:
def test_delete_removes_file(self):
def test_delete_removes_file(self, template_dir):
group = make_group()
group.save(TEMPLATE_TEST_PATH)
group.save(template_dir)
path = group.path
assert os.path.exists(path)
@@ -152,9 +140,9 @@ class TestGroupDelete:
# Should not raise
group.delete()
def test_delete_after_file_already_removed_does_not_raise(self):
def test_delete_after_file_already_removed_does_not_raise(self, template_dir):
group = make_group()
group.save(TEMPLATE_TEST_PATH)
group.save(template_dir)
os.remove(group.path)
# File already gone, should not raise
@@ -162,9 +150,9 @@ class TestGroupDelete:
class TestGroupUpdate:
def test_update_changes_metadata(self):
def test_update_changes_metadata(self, template_dir):
group = make_group(name="Original")
group.save(TEMPLATE_TEST_PATH)
group.save(template_dir)
updated = make_group(name="Updated", author="Bob", description="new desc")
group.update(updated)
@@ -177,10 +165,10 @@ class TestGroupUpdate:
loaded = Group.load(group.path)
assert loaded.name == "Updated"
def test_update_ignores_templates_by_default(self):
def test_update_ignores_templates_by_default(self, template_dir):
tmpl = StateReinforcement(**make_template())
group = make_group(templates=[tmpl])
group.save(TEMPLATE_TEST_PATH)
group.save(template_dir)
updated = make_group() # no templates
group.update(updated)
@@ -189,9 +177,9 @@ class TestGroupUpdate:
class TestGroupTemplateOperations:
def test_insert_template(self):
def test_insert_template(self, template_dir):
group = make_group()
group.save(TEMPLATE_TEST_PATH)
group.save(template_dir)
tmpl = StateReinforcement(**make_template(name="Inserted"))
group.insert_template(tmpl)
@@ -202,9 +190,9 @@ class TestGroupTemplateOperations:
loaded = Group.load(group.path)
assert tmpl.uid in loaded.templates
def test_insert_duplicate_raises(self):
def test_insert_duplicate_raises(self, template_dir):
group = make_group()
group.save(TEMPLATE_TEST_PATH)
group.save(template_dir)
tmpl = StateReinforcement(**make_template())
group.insert_template(tmpl)
@@ -212,10 +200,10 @@ class TestGroupTemplateOperations:
with pytest.raises(ValueError, match="already exists"):
group.insert_template(tmpl)
def test_update_template(self):
def test_update_template(self, template_dir):
tmpl = StateReinforcement(**make_template(name="V1"))
group = make_group(templates=[tmpl])
group.save(TEMPLATE_TEST_PATH)
group.save(template_dir)
tmpl.name = "V2"
group.update_template(tmpl)
@@ -223,10 +211,10 @@ class TestGroupTemplateOperations:
loaded = Group.load(group.path)
assert loaded.templates[tmpl.uid].name == "V2"
def test_delete_template(self):
def test_delete_template(self, template_dir):
tmpl = StateReinforcement(**make_template())
group = make_group(templates=[tmpl])
group.save(TEMPLATE_TEST_PATH)
group.save(template_dir)
group.delete_template(tmpl)
@@ -235,9 +223,9 @@ class TestGroupTemplateOperations:
loaded = Group.load(group.path)
assert tmpl.uid not in loaded.templates
def test_delete_nonexistent_template_is_noop(self):
def test_delete_nonexistent_template_is_noop(self, template_dir):
group = make_group()
group.save(TEMPLATE_TEST_PATH)
group.save(template_dir)
tmpl = StateReinforcement(**make_template())
# Should not raise
@@ -252,25 +240,25 @@ class TestGroupTemplateOperations:
class TestCollectionLoadFromDir:
def test_load_empty_directory(self):
collection = Collection.load(TEMPLATE_TEST_PATH)
def test_load_empty_directory(self, template_dir):
collection = Collection.load(template_dir)
assert len(collection.groups) == 0
def test_load_single_group(self):
def test_load_single_group(self, template_dir):
group = make_group(name="Solo")
group.save(TEMPLATE_TEST_PATH)
group.save(template_dir)
collection = Collection.load(TEMPLATE_TEST_PATH)
collection = Collection.load(template_dir)
assert len(collection.groups) == 1
assert collection.groups[0].uid == group.uid
def test_load_multiple_groups(self):
def test_load_multiple_groups(self, template_dir):
g1 = make_group(name="Group A")
g2 = make_group(name="Group B")
g1.save(TEMPLATE_TEST_PATH)
g2.save(TEMPLATE_TEST_PATH)
g1.save(template_dir)
g2.save(template_dir)
collection = Collection.load(TEMPLATE_TEST_PATH)
collection = Collection.load(template_dir)
assert len(collection.groups) == 2
loaded_uids = {g.uid for g in collection.groups}
assert g1.uid in loaded_uids
@@ -299,9 +287,9 @@ class TestCollectionFind:
class TestCollectionRemove:
def test_remove_deletes_group_and_file(self):
def test_remove_deletes_group_and_file(self, template_dir):
group = make_group()
group.save(TEMPLATE_TEST_PATH)
group.save(template_dir)
path = group.path
collection = Collection(groups=[group])
@@ -310,10 +298,10 @@ class TestCollectionRemove:
assert len(collection.groups) == 0
assert not os.path.exists(path)
def test_remove_by_deserialized_group(self):
def test_remove_by_deserialized_group(self, template_dir):
"""Simulates the real bug: removing via a different Group object with same uid."""
group = make_group(name="Original")
group.save(TEMPLATE_TEST_PATH)
group.save(template_dir)
path = group.path
collection = Collection(groups=[group])
@@ -338,9 +326,9 @@ class TestCollectionRemove:
with pytest.raises(ValueError, match="not found"):
collection.remove(group)
def test_remove_without_save(self):
def test_remove_without_save(self, template_dir):
group = make_group()
group.save(TEMPLATE_TEST_PATH)
group.save(template_dir)
path = group.path
collection = Collection(groups=[group])
@@ -351,17 +339,17 @@ class TestCollectionRemove:
class TestCollectionSave:
def test_save_persists_all_groups(self):
def test_save_persists_all_groups(self, template_dir):
g1 = make_group(name="Group One")
g2 = make_group(name="Group Two")
collection = Collection(groups=[g1, g2])
collection.save(TEMPLATE_TEST_PATH)
collection.save(template_dir)
assert os.path.exists(g1.path)
assert os.path.exists(g2.path)
# Verify by loading
loaded = Collection.load(TEMPLATE_TEST_PATH)
loaded = Collection.load(template_dir)
assert len(loaded.groups) == 2

View File

@@ -11,7 +11,6 @@ not exercised by `tests/test_world_state_templates.py`:
"""
import os
import shutil
import pytest
import yaml
@@ -28,18 +27,6 @@ from talemate.world_state.templates.base import (
from talemate.world_state.templates.state_reinforcement import StateReinforcement
TEMPLATE_TEST_PATH = os.path.join(os.path.dirname(__file__), "data", "templates_base")
@pytest.fixture(autouse=True)
def clean_template_dir():
if os.path.exists(TEMPLATE_TEST_PATH):
shutil.rmtree(TEMPLATE_TEST_PATH)
os.makedirs(TEMPLATE_TEST_PATH, exist_ok=True)
yield
shutil.rmtree(TEMPLATE_TEST_PATH)
def make_state_template(**overrides) -> StateReinforcement:
defaults = dict(
name="Test",
@@ -222,16 +209,16 @@ class TestSanitizeData:
yaml.dump(data, f)
return path
def test_loads_with_missing_uid_assigns_one(self):
path = os.path.join(TEMPLATE_TEST_PATH, "g.yaml")
def test_loads_with_missing_uid_assigns_one(self, template_dir):
path = os.path.join(template_dir, "g.yaml")
self._write(
path, {"author": "a", "name": "n", "description": "d", "templates": {}}
)
g = Group.load(path)
assert g.uid # assigned a new uuid
def test_loads_with_missing_name_assigns_uid_prefix(self):
path = os.path.join(TEMPLATE_TEST_PATH, "g.yaml")
def test_loads_with_missing_name_assigns_uid_prefix(self, template_dir):
path = os.path.join(template_dir, "g.yaml")
self._write(
path,
{
@@ -245,8 +232,8 @@ class TestSanitizeData:
g = Group.load(path)
assert g.name == "abcdefgh"
def test_loads_with_null_description_and_author(self):
path = os.path.join(TEMPLATE_TEST_PATH, "g.yaml")
def test_loads_with_null_description_and_author(self, template_dir):
path = os.path.join(template_dir, "g.yaml")
self._write(
path,
{
@@ -260,8 +247,8 @@ class TestSanitizeData:
assert g.description == ""
assert g.author == ""
def test_loads_drops_null_template(self):
path = os.path.join(TEMPLATE_TEST_PATH, "g.yaml")
def test_loads_drops_null_template(self, template_dir):
path = os.path.join(template_dir, "g.yaml")
self._write(
path,
{
@@ -275,8 +262,8 @@ class TestSanitizeData:
g = Group.load(path)
assert g.templates == {}
def test_loads_assigns_template_uid_from_key(self):
path = os.path.join(TEMPLATE_TEST_PATH, "g.yaml")
def test_loads_assigns_template_uid_from_key(self, template_dir):
path = os.path.join(template_dir, "g.yaml")
self._write(
path,
{
@@ -300,8 +287,8 @@ class TestSanitizeData:
# template.group should match the group's uid
assert g.templates["key1"].group == "g-uid"
def test_loads_assigns_template_name_from_key(self):
path = os.path.join(TEMPLATE_TEST_PATH, "g.yaml")
def test_loads_assigns_template_name_from_key(self, template_dir):
path = os.path.join(template_dir, "g.yaml")
self._write(
path,
{
@@ -322,11 +309,11 @@ class TestSanitizeData:
# name was missing -> set to first 8 chars of template_id
assert g.templates["abcdefghijkl"].name == "abcdefgh"
def test_loads_drops_template_with_missing_template_type(self):
def test_loads_drops_template_with_missing_template_type(self, template_dir):
# A template with no `template_type` field should be dropped (the
# missing-type branch deletes and `continue`s, so it doesn't fall
# into the invalid-type branch and double-delete).
path = os.path.join(TEMPLATE_TEST_PATH, "g.yaml")
path = os.path.join(template_dir, "g.yaml")
self._write(
path,
{
@@ -346,8 +333,8 @@ class TestSanitizeData:
g = Group.load(path)
assert "tid1" not in g.templates
def test_loads_drops_template_with_invalid_template_type(self):
path = os.path.join(TEMPLATE_TEST_PATH, "g.yaml")
def test_loads_drops_template_with_invalid_template_type(self, template_dir):
path = os.path.join(template_dir, "g.yaml")
self._write(
path,
{
@@ -367,8 +354,8 @@ class TestSanitizeData:
g = Group.load(path)
assert "tid1" not in g.templates
def test_loads_with_non_int_priority_falls_back_to_one(self):
path = os.path.join(TEMPLATE_TEST_PATH, "g.yaml")
def test_loads_with_non_int_priority_falls_back_to_one(self, template_dir):
path = os.path.join(template_dir, "g.yaml")
self._write(
path,
{

222
tests/test_xdist_workers.py Normal file
View File

@@ -0,0 +1,222 @@
"""Tests for the pytest-xdist worker-count hook in conftest.
The suite runs distributed by default (`addopts = -n auto`), and the worker
count comes from `pytest_xdist_auto_num_workers`. Getting it wrong is
expensive in both directions: too many workers on a CPU-limited container
makes every run slower, too few leaves cores idle.
"""
import sys
import types
import pytest
import conftest
@pytest.fixture
def cgroup_files(tmp_path, monkeypatch):
"""Point the cgroup lookups at a temporary directory.
Returns the (v2, v1_quota, v1_period) paths; a test writes only the ones
whose cgroup version it is standing in for, leaving the rest absent.
"""
v2 = tmp_path / "cpu.max"
v1_quota = tmp_path / "cpu.cfs_quota_us"
v1_period = tmp_path / "cpu.cfs_period_us"
monkeypatch.setattr(conftest, "_CGROUP_V2_CPU_MAX", v2)
monkeypatch.setattr(conftest, "_CGROUP_V1_CPU_QUOTA", v1_quota)
monkeypatch.setattr(conftest, "_CGROUP_V1_CPU_PERIOD", v1_period)
return v2, v1_quota, v1_period
class TestCgroupCpuQuota:
def test_reads_cgroup_v2_quota(self, cgroup_files):
v2, _, _ = cgroup_files
v2.write_text("400000 100000")
assert conftest._cgroup_cpu_quota() == 4.0
def test_cgroup_v2_max_means_unrestricted(self, cgroup_files):
v2, _, _ = cgroup_files
v2.write_text("max 100000")
assert conftest._cgroup_cpu_quota() is None
def test_reads_cgroup_v1_quota_when_v2_is_absent(self, cgroup_files):
_, v1_quota, v1_period = cgroup_files
v1_quota.write_text("200000")
v1_period.write_text("100000")
assert conftest._cgroup_cpu_quota() == 2.0
def test_cgroup_v1_negative_quota_means_unrestricted(self, cgroup_files):
_, v1_quota, v1_period = cgroup_files
v1_quota.write_text("-1")
v1_period.write_text("100000")
assert conftest._cgroup_cpu_quota() is None
def test_returns_none_when_no_cgroup_files_exist(self, cgroup_files):
assert conftest._cgroup_cpu_quota() is None
@pytest.fixture
def cpu_topology(monkeypatch):
"""Control every signal the worker-count hook consults.
Arguments to the returned setter:
- ``affinity``: usable cores, or None for a platform without
``os.sched_getaffinity`` (Windows, macOS), where the hook falls back to
``os.cpu_count()``.
- ``cpu_count``: what ``os.cpu_count()`` reports; defaults to ``affinity``.
- ``quota``: cgroup CPU quota, or None for unrestricted.
- ``physical``: psutil's physical-core count, or None to have psutil report
nothing. Pass ``psutil=False`` for psutil not being installed at all.
"""
def _set(affinity=8, quota=None, physical=None, psutil=True, cpu_count=...):
# The hook consults this before anything else, and a contributor who
# exports it would otherwise get None back from every test here.
monkeypatch.delenv("PYTEST_XDIST_AUTO_NUM_WORKERS", raising=False)
if affinity is None:
# raising=False: the attribute is absent on non-Linux to begin with.
monkeypatch.delattr(conftest.os, "sched_getaffinity", raising=False)
else:
monkeypatch.setattr(
conftest.os,
"sched_getaffinity",
lambda pid: set(range(affinity)),
raising=False,
)
if cpu_count is ...:
cpu_count = affinity
monkeypatch.setattr(conftest.os, "cpu_count", lambda: cpu_count)
monkeypatch.setattr(conftest, "_cgroup_cpu_quota", lambda: quota)
if psutil:
fake = types.SimpleNamespace(cpu_count=lambda logical=True: physical)
monkeypatch.setitem(sys.modules, "psutil", fake)
else:
# A None entry in sys.modules makes `import psutil` raise ImportError.
monkeypatch.setitem(sys.modules, "psutil", None)
return _set
class _Options:
def __init__(self, numprocesses="auto"):
self.numprocesses = numprocesses
class _Config:
def __init__(self, numprocesses="auto"):
self.option = _Options(numprocesses)
class TestAutoNumWorkers:
def test_zero_workers_on_a_single_cpu(self, cpu_topology):
"""0 tells xdist to stay in-process — distributing on one CPU is a
pure loss (a second interpreter plus IPC for every test)."""
cpu_topology(affinity=32, quota=1.0)
assert conftest.pytest_xdist_auto_num_workers(_Config()) == 0
def test_quota_wins_over_visible_cpu_count(self, cpu_topology):
"""A container can see every host core while being allowed a few."""
cpu_topology(affinity=32, quota=4.0)
assert conftest.pytest_xdist_auto_num_workers(_Config()) == 4
def test_affinity_is_honoured_without_any_quota(self, cpu_topology):
"""`--cpuset-cpus`, taskset and Slurm pin cores without setting a
quota, so affinity has to be consulted on its own."""
cpu_topology(affinity=2, quota=None)
assert conftest.pytest_xdist_auto_num_workers(_Config()) == 2
def test_caps_at_max_test_workers(self, cpu_topology):
cpu_topology(affinity=64, quota=None)
assert (
conftest.pytest_xdist_auto_num_workers(_Config())
== conftest.MAX_TEST_WORKERS
)
def test_fractional_quota_rounds_down_to_serial(self, cpu_topology):
"""0.5 of a CPU is less than one, so there is nothing to distribute."""
cpu_topology(affinity=8, quota=0.5)
assert conftest.pytest_xdist_auto_num_workers(_Config()) == 0
def test_env_override_defers_to_xdist(self, cpu_topology, monkeypatch):
"""Returning None lets xdist's own implementation run, which is the
only one that reads its documented override."""
cpu_topology(affinity=8, quota=None)
monkeypatch.setenv("PYTEST_XDIST_AUTO_NUM_WORKERS", "3")
assert conftest.pytest_xdist_auto_num_workers(_Config()) is None
def test_topology_fixture_clears_the_xdist_override(
self, cpu_topology, monkeypatch
):
"""Pins `cpu_topology`'s `delenv`. Without it every test in this class
returns None for anyone who has the override exported — and CI, where
it is unset, could never notice the guard had been removed."""
monkeypatch.setenv("PYTEST_XDIST_AUTO_NUM_WORKERS", "3")
cpu_topology(affinity=4, quota=None)
assert conftest.pytest_xdist_auto_num_workers(_Config()) == 4
def test_auto_prefers_physical_cores(self, cpu_topology):
"""`-n auto` means physical cores; affinity counts hyperthreads, so
oversubscribing them on a CPU-bound suite just adds contention."""
cpu_topology(affinity=8, quota=None, physical=4)
assert conftest.pytest_xdist_auto_num_workers(_Config("auto")) == 4
def test_logical_uses_the_hyperthread_count(self, cpu_topology):
cpu_topology(affinity=8, quota=None, physical=4)
assert conftest.pytest_xdist_auto_num_workers(_Config("logical")) == 8
def test_quota_still_wins_over_physical_core_count(self, cpu_topology):
cpu_topology(affinity=16, quota=2.0, physical=8)
assert conftest.pytest_xdist_auto_num_workers(_Config()) == 2
def test_falls_back_to_cpu_count_without_sched_getaffinity(self, cpu_topology):
"""Windows and macOS have no ``os.sched_getaffinity`` — the branch every
non-Linux contributor actually runs."""
cpu_topology(affinity=None, cpu_count=4, quota=None)
assert conftest.pytest_xdist_auto_num_workers(_Config()) == 4
def test_unknown_cpu_count_falls_back_to_serial(self, cpu_topology):
"""``os.cpu_count()`` returns None when it cannot tell."""
cpu_topology(affinity=None, cpu_count=None, quota=None)
assert conftest.pytest_xdist_auto_num_workers(_Config()) == 0
def test_single_physical_core_behind_hyperthreads_is_serial(self, cpu_topology):
"""One worker is the worst of both worlds — the full IPC and startup
cost for no parallelism — so the `< 2` check has to be applied to the
clamped count, not the count before it."""
cpu_topology(affinity=4, quota=None, physical=1)
assert conftest.pytest_xdist_auto_num_workers(_Config("auto")) == 0
def test_logical_still_uses_hyperthreads_on_one_physical_core(self, cpu_topology):
cpu_topology(affinity=4, quota=None, physical=1)
assert conftest.pytest_xdist_auto_num_workers(_Config("logical")) == 4
def test_missing_psutil_falls_back_to_the_affinity_count(self, cpu_topology):
"""Without psutil there is no way to tell physical cores from
hyperthreads, so the usable-CPU count stands in for both."""
cpu_topology(affinity=6, quota=None, psutil=False)
assert conftest.pytest_xdist_auto_num_workers(_Config()) == 6

76
uv.lock generated
View File

@@ -14,7 +14,7 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-07-13T14:41:13.850459358Z"
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
exclude-newer-span = "P1W"
[options.exclude-newer-package]
@@ -1127,7 +1127,7 @@ name = "cuda-bindings"
version = "12.9.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cuda-pathfinder", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "cuda-pathfinder" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/f3/f9d1095f90d2a4df24cfcafe7487fd9444c6dacb94e3722be6fedd8ac26c/cuda_bindings-12.9.7-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16043ef5b15ab88fe9954c5c2061b1d8007591b27f2c916331056de0ebc6187e", size = 7114834, upload-time = "2026-05-27T18:44:07.746Z" },
@@ -1158,37 +1158,37 @@ wheels = [
[package.optional-dependencies]
cublas = [
{ name = "nvidia-cublas-cu12", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cublas-cu12" },
]
cudart = [
{ name = "nvidia-cuda-runtime-cu12", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cuda-runtime-cu12" },
]
cufft = [
{ name = "nvidia-cufft-cu12", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cufft-cu12" },
]
cufile = [
{ name = "nvidia-cufile-cu12", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cufile-cu12" },
]
cupti = [
{ name = "nvidia-cuda-cupti-cu12", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cuda-cupti-cu12" },
]
curand = [
{ name = "nvidia-curand-cu12", marker = "sys_platform == 'linux'" },
{ name = "nvidia-curand-cu12" },
]
cusolver = [
{ name = "nvidia-cusolver-cu12", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cusolver-cu12" },
]
cusparse = [
{ name = "nvidia-cusparse-cu12", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cusparse-cu12" },
]
nvjitlink = [
{ name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux'" },
{ name = "nvidia-nvjitlink-cu12" },
]
nvrtc = [
{ name = "nvidia-cuda-nvrtc-cu12", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cuda-nvrtc-cu12" },
]
nvtx = [
{ name = "nvidia-nvtx-cu12", marker = "sys_platform == 'linux'" },
{ name = "nvidia-nvtx-cu12" },
]
[[package]]
@@ -1507,6 +1507,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/50/a7/bb99bf5e6f78736ddb53480f2c3ff3702ffe2196a7c5e1661c03081d398e/eval_type_backport-0.4.0-py3-none-any.whl", hash = "sha256:ad5e2a8db71b6696a56eafb938b0f5a337d3217f256b8e158b469422b4772b20", size = 6432, upload-time = "2026-06-02T13:22:04.827Z" },
]
[[package]]
name = "execnet"
version = "2.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" },
]
[[package]]
name = "f5-tts"
version = "1.1.17"
@@ -3505,7 +3514,7 @@ name = "nvidia-cudnn-cu12"
version = "9.19.0.56"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas-cu12", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cublas-cu12" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/09/b8/277c51962ee46fa3e5b203ac5f76107c650f781d6891e681e28e6f3e9fe6/nvidia_cudnn_cu12-9.19.0.56-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:08caaf27fe556aca82a3ee3b5aa49a77e7de0cfcb7ff4e5c29da426387a8267e", size = 656910700, upload-time = "2026-02-03T20:40:25.508Z" },
@@ -3517,7 +3526,7 @@ name = "nvidia-cufft-cu12"
version = "11.3.3.83"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-nvjitlink-cu12" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/bc/7771846d3a0272026c416fbb7e5f4c1f146d6d80704534d0b187dd6f4800/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:848ef7224d6305cdb2a4df928759dca7b1201874787083b6e7550dd6765ce69a", size = 193109211, upload-time = "2025-03-07T01:44:56.873Z" },
@@ -3547,9 +3556,9 @@ name = "nvidia-cusolver-cu12"
version = "11.7.3.90"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas-cu12", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cusparse-cu12", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cublas-cu12" },
{ name = "nvidia-cusparse-cu12" },
{ name = "nvidia-nvjitlink-cu12" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/32/f7cd6ce8a7690544d084ea21c26e910a97e077c9b7f07bf5de623ee19981/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:db9ed69dbef9715071232caa9b69c52ac7de3a95773c2db65bdba85916e4e5c0", size = 267229841, upload-time = "2025-03-07T01:46:54.356Z" },
@@ -3561,7 +3570,7 @@ name = "nvidia-cusparse-cu12"
version = "12.5.8.93"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-nvjitlink-cu12" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/f7/cd777c4109681367721b00a106f491e0d0d15cfa1fd59672ce580ce42a97/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b6c161cb130be1a07a27ea6923df8141f3c295852f4b260c65f18f3e0a091dc", size = 288117129, upload-time = "2025-03-07T01:47:40.407Z" },
@@ -4685,6 +4694,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "pytest-xdist"
version = "3.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "execnet" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@@ -5295,7 +5317,7 @@ resolution-markers = [
"python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
dependencies = [
{ name = "numpy", marker = "python_full_version < '3.12'" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
wheels = [
@@ -5354,7 +5376,7 @@ resolution-markers = [
"python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.12'" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" }
wheels = [
@@ -5385,8 +5407,8 @@ name = "secretstorage"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "jeepney", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "cryptography" },
{ name = "jeepney" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
wheels = [
@@ -5795,8 +5817,8 @@ name = "standard-aifc"
version = "3.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" },
{ name = "standard-chunk", marker = "python_full_version >= '3.13'" },
{ name = "audioop-lts" },
{ name = "standard-chunk" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" }
wheels = [
@@ -5817,7 +5839,7 @@ name = "standard-sunau"
version = "3.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" },
{ name = "audioop-lts" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/e3/ce8d38cb2d70e05ffeddc28bb09bad77cfef979eb0a299c9117f7ed4e6a9/standard_sunau-3.13.0.tar.gz", hash = "sha256:b319a1ac95a09a2378a8442f403c66f4fd4b36616d6df6ae82b8e536ee790908", size = 9368, upload-time = "2024-10-30T16:01:41.626Z" }
wheels = [
@@ -5931,6 +5953,7 @@ dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-xdist" },
]
[package.metadata]
@@ -5980,6 +6003,7 @@ requires-dist = [
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=6.2" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.25.3" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" },
{ name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.8" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "requests", specifier = ">=2.26" },