diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64d9a13a..56642505 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 482c71d8..9a44dc6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/talemate/agents/tts/chatterbox.py b/src/talemate/agents/tts/chatterbox.py index 45bf94ea..53c71275 100644 --- a/src/talemate/agents/tts/chatterbox.py +++ b/src/talemate/agents/tts/chatterbox.py @@ -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"}, diff --git a/src/talemate/agents/tts/f5tts.py b/src/talemate/agents/tts/f5tts.py index 5121ee16..22e878f0 100644 --- a/src/talemate/agents/tts/f5tts.py +++ b/src/talemate/agents/tts/f5tts.py @@ -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"}, diff --git a/src/talemate/agents/tts/kokoro.py b/src/talemate/agents/tts/kokoro.py index 81d3aa4e..c8c49081 100644 --- a/src/talemate/agents/tts/kokoro.py +++ b/src/talemate/agents/tts/kokoro.py @@ -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 diff --git a/src/talemate/agents/tts/pocket_tts.py b/src/talemate/agents/tts/pocket_tts.py index 5f884bff..c4c3905d 100644 --- a/src/talemate/agents/tts/pocket_tts.py +++ b/src/talemate/agents/tts/pocket_tts.py @@ -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 diff --git a/src/talemate/util/gpu.py b/src/talemate/util/gpu.py index 93476c5a..50e0b443 100644 --- a/src/talemate/util/gpu.py +++ b/src/talemate/util/gpu.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 97d461a8..31b19619 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_director_character_management.py b/tests/test_director_character_management.py index 02df8c61..f7e4d4d5 100644 --- a/tests/test_director_character_management.py +++ b/tests/test_director_character_management.py @@ -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={}) diff --git a/tests/test_global_state_isolation.py b/tests/test_global_state_isolation.py new file mode 100644 index 00000000..30940938 --- /dev/null +++ b/tests/test_global_state_isolation.py @@ -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 diff --git a/tests/test_graphs.py b/tests/test_graphs.py index 758e8079..a7d9e1ce 100644 --- a/tests/test_graphs.py +++ b/tests/test_graphs.py @@ -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" diff --git a/tests/test_tts_agent.py b/tests/test_tts_agent.py index 7b07ee7a..0ff2feac 100644 --- a/tests/test_tts_agent.py +++ b/tests/test_tts_agent.py @@ -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): diff --git a/tests/test_world_state_templates.py b/tests/test_world_state_templates.py index dca92f37..65aceb6c 100644 --- a/tests/test_world_state_templates.py +++ b/tests/test_world_state_templates.py @@ -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 diff --git a/tests/test_world_state_templates_base.py b/tests/test_world_state_templates_base.py index 83b71e82..545c180d 100644 --- a/tests/test_world_state_templates_base.py +++ b/tests/test_world_state_templates_base.py @@ -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, { diff --git a/tests/test_xdist_workers.py b/tests/test_xdist_workers.py new file mode 100644 index 00000000..744b112d --- /dev/null +++ b/tests/test_xdist_workers.py @@ -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 diff --git a/uv.lock b/uv.lock index 7f01a80e..77411db2 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },