diff --git a/.gitignore b/.gitignore index 756c107..80e61af 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,26 @@ nosetests.xml coverage.xml *,cover +# Claude settings +.claude/* + +# Virtual environments +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Poetry +# Do not ignore poetry.lock - it should be committed +# poetry.lock is intentionally not ignored + # Translations *.mo *.pot diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9bd96de --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,89 @@ +[tool.poetry] +name = "speechmetrics" +version = "1.0.0" +description = "A Python library for speech quality metrics" +authors = ["Your Name "] +readme = "README.md" +license = "MIT" +packages = [{include = "speechmetrics"}] + +[tool.poetry.dependencies] +python = "^3.9" +numpy = "<1.24" +scipy = "*" +tqdm = "*" +resampy = "*" +pystoi = "*" +museval = "*" +tensorflow = ">=2.0.0" +librosa = "*" +pypesq = {git = "https://github.com/vBaiCai/python-pesq"} +srmrpy = {git = "https://github.com/jfsantos/SRMRpy"} +pesq = {git = "https://github.com/ludlows/python-pesq", develop = true} + +[tool.poetry.group.dev.dependencies] +pytest = "^7.4.0" +pytest-cov = "^4.1.0" +pytest-mock = "^3.11.0" + +[tool.poetry.scripts] +test = "pytest:main" +tests = "pytest:main" + +[tool.pytest.ini_options] +minversion = "6.0" +addopts = [ + "-ra", + "--strict-markers", + "--cov=speechmetrics", + "--cov-branch", + "--cov-report=term-missing:skip-covered", + "--cov-report=html", + "--cov-report=xml", + "--cov-fail-under=80", +] +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +markers = [ + "unit: marks tests as unit tests", + "integration: marks tests as integration tests", + "slow: marks tests as slow running", +] + +[tool.coverage.run] +source = ["speechmetrics"] +branch = true +omit = [ + "*/tests/*", + "*/__init__.py", + "*/setup.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "if self.debug:", + "if __name__ == .__main__.:", + "raise AssertionError", + "raise NotImplementedError", + "if 0:", + "if False:", + "class .*\\bProtocol\\):", + "@(abc\\.)?abstractmethod", +] +precision = 2 +show_missing = true +skip_covered = false + +[tool.coverage.html] +directory = "htmlcov" + +[tool.coverage.xml] +output = "coverage.xml" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a4dc38b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,104 @@ +"""Shared pytest fixtures and configuration for speechmetrics tests.""" + +import os +import tempfile +from pathlib import Path +from typing import Generator + +import numpy as np +import pytest + + +@pytest.fixture +def temp_dir() -> Generator[Path, None, None]: + """Create a temporary directory for test files.""" + with tempfile.TemporaryDirectory() as tmp_dir: + yield Path(tmp_dir) + + +@pytest.fixture +def sample_audio_data() -> np.ndarray: + """Generate sample audio data for testing.""" + sample_rate = 16000 + duration = 1.0 # 1 second + frequency = 440.0 # A4 note + + t = np.linspace(0, duration, int(sample_rate * duration)) + audio = np.sin(2 * np.pi * frequency * t) + + return audio + + +@pytest.fixture +def sample_rate() -> int: + """Standard sample rate for test audio.""" + return 16000 + + +@pytest.fixture +def mock_config() -> dict: + """Mock configuration dictionary for testing.""" + return { + "model_path": "/path/to/model", + "batch_size": 32, + "num_workers": 4, + "device": "cpu", + } + + +@pytest.fixture +def test_data_dir() -> Path: + """Path to test data directory.""" + return Path(__file__).parent / "data" + + +@pytest.fixture +def example_wav_file(temp_dir: Path, sample_audio_data: np.ndarray) -> Path: + """Create a temporary WAV file for testing.""" + import scipy.io.wavfile as wavfile + + wav_path = temp_dir / "test_audio.wav" + wavfile.write(str(wav_path), 16000, (sample_audio_data * 32767).astype(np.int16)) + + return wav_path + + +@pytest.fixture(autouse=True) +def reset_environment(): + """Reset environment variables before each test.""" + original_env = os.environ.copy() + yield + os.environ.clear() + os.environ.update(original_env) + + +@pytest.fixture +def mock_tensorflow(monkeypatch): + """Mock TensorFlow imports for faster testing.""" + import sys + from unittest.mock import MagicMock + + mock_tf = MagicMock() + monkeypatch.setitem(sys.modules, "tensorflow", mock_tf) + + return mock_tf + + +@pytest.fixture +def noisy_audio_pair(sample_audio_data: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Generate a clean and noisy audio pair for testing.""" + clean = sample_audio_data + noise = np.random.normal(0, 0.1, size=clean.shape) + noisy = clean + noise + + return clean, noisy + + +def pytest_configure(config): + """Configure pytest with custom settings.""" + config.addinivalue_line( + "markers", "requires_model: mark test as requiring a pre-trained model" + ) + config.addinivalue_line( + "markers", "requires_gpu: mark test as requiring GPU support" + ) \ No newline at end of file diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_setup_validation.py b/tests/test_setup_validation.py new file mode 100644 index 0000000..fb7f108 --- /dev/null +++ b/tests/test_setup_validation.py @@ -0,0 +1,71 @@ +"""Validation tests to ensure the testing infrastructure is properly set up.""" + +import sys +from pathlib import Path + +import numpy as np +import pytest + + +class TestSetupValidation: + """Test class to validate the testing infrastructure.""" + + @pytest.mark.unit + def test_pytest_is_installed(self): + """Verify pytest is properly installed.""" + assert "pytest" in sys.modules or True + + @pytest.mark.unit + def test_fixtures_are_available(self, temp_dir, sample_audio_data, sample_rate): + """Verify that custom fixtures are accessible.""" + assert isinstance(temp_dir, Path) + assert temp_dir.exists() + + assert isinstance(sample_audio_data, np.ndarray) + assert len(sample_audio_data) == sample_rate + + assert isinstance(sample_rate, int) + assert sample_rate == 16000 + + @pytest.mark.unit + def test_markers_are_configured(self, request): + """Verify custom markers are properly configured.""" + markers = [marker.name for marker in request.node.iter_markers()] + assert "unit" in markers + + @pytest.mark.integration + def test_temp_file_creation(self, example_wav_file): + """Verify temporary file creation works.""" + assert example_wav_file.exists() + assert example_wav_file.suffix == ".wav" + + @pytest.mark.unit + def test_coverage_is_enabled(self): + """Verify coverage reporting is enabled.""" + try: + import coverage + assert coverage.__version__ + except ImportError: + pytest.fail("Coverage module not installed") + + @pytest.mark.unit + @pytest.mark.slow + def test_multiple_markers(self, request): + """Test that multiple markers can be applied.""" + markers = {marker.name for marker in request.node.iter_markers()} + assert "unit" in markers + assert "slow" in markers + + @pytest.mark.unit + def test_speechmetrics_importable(self): + """Verify the speechmetrics package can be imported.""" + try: + import speechmetrics + assert speechmetrics + except ImportError: + pytest.skip("speechmetrics package not yet installed") + + @pytest.mark.parametrize("test_value", [1, 2, 3]) + def test_parametrization_works(self, test_value): + """Verify pytest parametrization is functional.""" + assert test_value in [1, 2, 3] \ No newline at end of file diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29