mirror of
https://github.com/vegu-ai/talemate.git
synced 2026-08-29 10:08:58 +02:00
* 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:
@@ -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.
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={})
|
||||
|
||||
|
||||
176
tests/test_global_state_isolation.py
Normal file
176
tests/test_global_state_isolation.py
Normal 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
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
222
tests/test_xdist_workers.py
Normal 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
76
uv.lock
generated
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user